mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23c8d97f21 |
@@ -88,7 +88,6 @@ jobs:
|
||||
env:
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
|
||||
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
|
||||
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
|
||||
# They are outside our control and their transient failures should not block merges.
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review"
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
|
||||
with:
|
||||
script: |
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
|
||||
|
||||
@@ -206,7 +206,6 @@ temp*/
|
||||
.temp/
|
||||
|
||||
# AI
|
||||
**/.checkpoints/
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 136 KiB |
@@ -1125,7 +1125,7 @@ Naming (Python): N/A (Composable Components)
|
||||
Supports: N
|
||||
Observation: No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution.
|
||||
|
||||
No specific code examples available for interception.
|
||||
For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception.
|
||||
|
||||
#### Smolagents (Hugging Face)
|
||||
|
||||
|
||||
@@ -41,19 +41,19 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.12.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -72,12 +72,12 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||
@@ -86,12 +86,12 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
@@ -99,7 +99,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
|
||||
@@ -344,9 +344,6 @@
|
||||
<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-Toolbox-AuthPaths/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.10.0</VersionPrefix>
|
||||
<VersionPrefix>1.9.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260610</DateSuffix>
|
||||
<DateSuffix>260603</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.10.0</GitTag>
|
||||
<GitTag>1.9.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
-1
@@ -6,7 +6,6 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,22 +2,21 @@
|
||||
|
||||
// This sample shows how to create a GitHub Copilot agent with shell command permissions.
|
||||
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Permission handler that prompts the user for approval
|
||||
static Task<PermissionDecision> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
|
||||
static Task<PermissionRequestResult> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
|
||||
{
|
||||
Console.WriteLine($"\n[Permission Request: {request.Kind}]");
|
||||
Console.Write("Approve? (y/n): ");
|
||||
|
||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
PermissionDecision decision = input is "Y" or "YES"
|
||||
? PermissionDecision.ApproveOnce()
|
||||
: PermissionDecision.Reject();
|
||||
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||
? PermissionRequestResultKind.Approved
|
||||
: PermissionRequestResultKind.Rejected;
|
||||
|
||||
return Task.FromResult(decision);
|
||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||
}
|
||||
|
||||
// Create and start a Copilot client
|
||||
|
||||
@@ -36,7 +36,7 @@ dotnet run
|
||||
You can customize the agent by providing additional configuration:
|
||||
|
||||
```csharp
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Create and start a Copilot client
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.19.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.0" />
|
||||
<PackageReference Include="Neo4j.AgentFramework.GraphRAG" Version="0.1.0-preview.2" />
|
||||
<PackageReference Include="Neo4j.Driver" Version="5.28.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -79,10 +79,8 @@ AIAgent agent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
|
||||
+2
-6
@@ -44,10 +44,8 @@ AIAgent webSearchAgent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
@@ -94,10 +92,8 @@ AIAgent parentAgent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using background agents.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
|
||||
@@ -68,10 +68,8 @@ AIAgent agent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
|
||||
@@ -89,10 +89,8 @@ AIAgent agent =
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "CodeExecutionAgent",
|
||||
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
|
||||
-28
@@ -71,34 +71,6 @@ curl -X POST http://localhost:8088/invocations \
|
||||
-d "Hello from Docker!"
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-invocations-echo-agent && cd hosted-invocations-echo-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-invocations-echo-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## 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 `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -107,29 +107,3 @@ azd env set SKILL_NAMES "support-style,escalation-policy"
|
||||
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
|
||||
|
||||
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
|
||||
|
||||
### Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-agent-skills && cd hosted-agent-skills
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-agent-skills
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
-28
@@ -174,34 +174,6 @@ The model receives the top three search results as additional context and cites
|
||||
|
||||
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.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-azure-search-rag && cd hosted-azure-search-rag
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-azure-search-rag
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
-26
@@ -104,32 +104,6 @@ curl -X POST http://localhost:8088/responses \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-chat-client-agent && cd hosted-chat-client-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-chat-client-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
## 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` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -112,34 +112,6 @@ docker run --rm -p 8088:8088 \
|
||||
|
||||
The bundled `resources/` folder is part of the published output and ships inside the image.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-files && cd hosted-files
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-files
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -107,32 +107,6 @@ curl -X POST http://localhost:8088/responses \
|
||||
-d '{"input": "Hello!", "model": "<your-agent-name>"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-foundry-agent && cd hosted-foundry-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-foundry-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
## 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` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -108,34 +108,6 @@ The agent has a single tool `GetAvailableHotels` defined as a C# method with `[D
|
||||
|
||||
The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-local-tools && cd hosted-local-tools
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-local-tools
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## 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 `HostedLocalTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -78,40 +78,6 @@ docker run --rm -p 8088:8088 \
|
||||
hosted-mcp-tools
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir mcp-tools && cd mcp-tools
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME mcp-tools
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Related samples
|
||||
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`).
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
|
||||
|
||||
@@ -139,34 +139,6 @@ The script publishes the project, builds the image, runs the container with two
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-memory-agent && cd hosted-memory-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-memory-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the
|
||||
|
||||
-28
@@ -104,34 +104,6 @@ docker run --rm -p 8088:8088 \
|
||||
|
||||
Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-observability && cd hosted-observability
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-observability
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -111,34 +111,6 @@ The `TextSearchProvider` runs a mock search **before each model invocation**:
|
||||
|
||||
The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-text-rag && cd hosted-text-rag
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-text-rag
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## 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 `HostedTextRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
# Azure AI Foundry project endpoint (auto-injected in hosted containers).
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-foundry-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
|
||||
# Model deployment name. Must exist in the Foundry project above.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Name of the Foundry Toolbox you provisioned in the portal (see README.md).
|
||||
TOOLBOX_NAME=auth-paths-toolbox
|
||||
|
||||
# Agent name advertised over the wire. Must be unique if running side-by-side with
|
||||
# other Hosted-* samples (e.g. Hosted-Toolbox), otherwise the REPL client cannot
|
||||
# disambiguate which agent to chat with.
|
||||
AGENT_NAME=hosted-toolbox-auth-paths-agent
|
||||
|
||||
# Application Insights connection string (auto-injected in hosted containers; optional locally).
|
||||
# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# 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", "HostedToolboxAuthPaths.dll"]
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local source, which means a standard
|
||||
# multi-stage Docker build cannot resolve dependencies outside this folder.
|
||||
# Pre-publish the app targeting the container runtime and copy the output:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-toolbox-auth-paths .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-toolbox-auth-paths-agent \
|
||||
# -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
# --env-file .env hosted-toolbox-auth-paths
|
||||
#
|
||||
# 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", "HostedToolboxAuthPaths.dll"]
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolboxAuthPaths</RootNamespace>
|
||||
<AssemblyName>HostedToolboxAuthPaths</AssemblyName>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<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="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.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>
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Auth Paths Agent — A hosted agent backed by a single Foundry Toolbox
|
||||
// that bundles MCP tools using THREE different authentication paths.
|
||||
//
|
||||
// This sample demonstrates the same hosting bones as Hosted-Toolbox/, but the toolbox
|
||||
// (provisioned by the user out-of-band) contains three MCP tool entries each authenticated
|
||||
// differently. The agent code itself is agnostic to authentication — the educational
|
||||
// surface lives in the toolbox configuration in the Foundry portal and in this sample's
|
||||
// README.md.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
|
||||
// - Azure AI Foundry project endpoint. The Foundry hosted
|
||||
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
|
||||
// set AZURE_AI_PROJECT_ENDPOINT (the AF-repo convention).
|
||||
// TOOLBOX_NAME - Name of the Foundry Toolbox to load
|
||||
// (default: auth-paths-toolbox)
|
||||
//
|
||||
// Optional:
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// AGENT_NAME - Defaults to "hosted-toolbox-auth-paths-agent".
|
||||
//
|
||||
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
|
||||
// per tools-integration-spec.md §2–§3, so the sample does not need to plumb any
|
||||
// toolbox-specific URL env var.
|
||||
//
|
||||
// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other than the platform-injected ones
|
||||
// listed above) are reserved by the Foundry container platform and rejected by the
|
||||
// agent-create API. Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for sample-owned config.
|
||||
|
||||
#pragma warning disable OPENAI001 // FoundryAITool.CreateHostedMcpToolbox is experimental
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// Project endpoint resolution order:
|
||||
// 1. FOUNDRY_PROJECT_ENDPOINT — auto-injected by the Foundry hosted runtime.
|
||||
// 2. AZURE_AI_PROJECT_ENDPOINT — the convention developers set locally for `dotnet run`.
|
||||
// When deployed, only (1) is available; the AF-repo sample convention to set (2) at
|
||||
// deploy time fails silently because the platform reserves all FOUNDRY_* env-var names
|
||||
// and rejects them at agent-create time. Read both, prefer the platform-injected one.
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
|
||||
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "auth-paths-toolbox";
|
||||
string agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-auth-paths-agent";
|
||||
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent:
|
||||
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name)
|
||||
// below registers the toolbox with the Foundry.Hosting layer, which resolves that
|
||||
// toolbox's MCP tools once at startup and automatically makes them available to the
|
||||
// agent on every request. The agent code does nothing per request.
|
||||
// - Per-request / caller-driven (NOT used here): a client can attach a toolbox for a
|
||||
// single call by placing a FoundryAITool.CreateHostedMcpToolbox(name) marker in the
|
||||
// request body's tool list.
|
||||
// Because this sample bakes the toolbox in on the server, it uses AddFoundryToolboxes and
|
||||
// does NOT put the CreateHostedMcpToolbox marker in the agent's `tools:` array.
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to several tools, each provided by a different
|
||||
upstream service authenticated through a distinct mechanism (API key, agent managed
|
||||
identity, and a literal token
|
||||
shipped with the tool definition). Pick the tool that best fits the user's question
|
||||
and explain which upstream service answered when you respond.
|
||||
""",
|
||||
name: agentName,
|
||||
description: "Hosted agent demonstrating three MCP-tool authentication paths via a Foundry Toolbox.");
|
||||
|
||||
// Tier 3 spine (WebApplication.CreateBuilder + AddFoundryResponses + MapFoundryResponses):
|
||||
// the Foundry.Hosting package auto-maps the spec-required GET /readiness probe inside
|
||||
// MapFoundryResponses (idempotent — skipped when AgentHost or the developer already
|
||||
// mapped it), so the sample stays free of platform plumbing.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox://
|
||||
// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup
|
||||
// MCP tools/list against the toolbox proxy is typically <100ms in-region.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry
|
||||
// uses so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <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 = 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.MaxValue);
|
||||
}
|
||||
}
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
# Hosted Toolbox — Authentication Paths
|
||||
|
||||
A hosted Foundry agent backed by a single Foundry Toolbox that bundles MCP tools using **three different authentication paths**. The educational surface lives in the toolbox configuration (which you provision in the Foundry portal) and in this README — the agent code itself is identical to the existing [`Hosted-Toolbox/`](../Hosted-Toolbox/) sample.
|
||||
|
||||
Drive the agent interactively across the auth paths with the shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL client, pointed at this agent.
|
||||
|
||||
## What this sample teaches
|
||||
|
||||
| Aspect | This sample | Existing siblings |
|
||||
|---|---|---|
|
||||
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
|
||||
| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool |
|
||||
| Consumption | Server-side (Foundry resolves the marker) | Same |
|
||||
| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client |
|
||||
|
||||
Related samples:
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — simpler single-tool toolbox.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
|
||||
## Authentication-path matrix
|
||||
|
||||
The sample's purpose is to enumerate every authentication path a Foundry toolbox can drive, so each path appears alongside the others. Pick the ones your scenario needs — each connection in a toolbox is independent.
|
||||
|
||||
| # | Auth method | MCP target | Connection `authType` | What flows where | When to pick this |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **Key-based via project connection** | GitHub MCP at `https://api.githubcopilot.com/mcp` | `CustomKeys` | A PAT stored as `Authorization: Bearer <pat>` lives in the Foundry connection. The toolbox proxy reads it server-side and injects on every MCP call. | The upstream service only accepts API keys or PATs. |
|
||||
| 2 | **Microsoft Entra — agent identity** | Any Azure Cognitive Services MCP endpoint your project can reach (e.g., Language service MCP) | `AgenticIdentityToken` | Foundry mints an Entra token for the agent's own identity (`instance_identity` in the new agent object model), scoped to the connection's `audience`, and forwards it to the MCP server. The agent identity must hold the required role (typically `Cognitive Services User`) on the target resource. | Per-agent least-privilege access to Entra-protected services. Recommended default for new agents. |
|
||||
| 3 | **Inline `Authorization` (anti-pattern)** | `https://gitmcp.io/Azure/azure-rest-api-specs` | none | A literal bearer string lives on the toolbox tool entry's `authorization` field. **Do not do this in production** — there's no rotation, no secret store, no per-user identity. Shown for completeness. | Local-dev or public MCP servers that accept any (or no) bearer. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 0. (Path #2 only) Identify an Entra-authenticated MCP target
|
||||
|
||||
Path #2 requires an MCP server that accepts Microsoft Entra tokens. Any **Azure Cognitive Services** resource that exposes an MCP endpoint works — they all accept Entra ID tokens and gate access via standard RBAC.
|
||||
|
||||
The reference walkthrough below uses an **Azure Language service** MCP endpoint:
|
||||
|
||||
```
|
||||
https://<your-language-service>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview
|
||||
```
|
||||
|
||||
Substitute any other Cognitive Services MCP endpoint you have. If your project has none, omit tool #2 from your toolbox — the remaining two paths still work.
|
||||
|
||||
#### RBAC for path #2
|
||||
|
||||
Grant the **`Cognitive Services User`** role on the target resource to the agent's instance identity. Find it on the agent ARM resource (Azure portal → your agent → JSON view) at `instance_identity.principal_id`. This is the principal the Foundry proxy uses when minting tokens for `AgenticIdentityToken` connections.
|
||||
|
||||
```powershell
|
||||
$lang = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<lang-svc>"
|
||||
|
||||
az role assignment create `
|
||||
--assignee-object-id <agent-instance-identity-principal-id> `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role "Cognitive Services User" `
|
||||
--scope $lang
|
||||
```
|
||||
|
||||
Repeat for any additional Cognitive Services resources the agent identity needs to call.
|
||||
|
||||
> The RBAC grant requires `Microsoft.Authorization/roleAssignments/write` on the target scope. In many enterprise subscriptions this needs a PIM JIT activation.
|
||||
|
||||
### 1. Foundry project + Azure AI User role
|
||||
|
||||
- An active Microsoft Foundry project ([create one](https://learn.microsoft.com/en-us/azure/foundry/how-to/create-projects)).
|
||||
- The **Azure AI User** role on the project assigned to:
|
||||
- The developer (you) creating the toolbox.
|
||||
- The agent identity for tool invocation.
|
||||
|
||||
### 2. Create the project connections
|
||||
|
||||
The Entra-based connection (path #2) is not available in the Foundry portal connection wizard today. Create it via ARM REST:
|
||||
|
||||
```powershell
|
||||
$armToken = az account get-access-token --query accessToken -o tsv
|
||||
$h = @{ Authorization = "Bearer $armToken"; "Content-Type" = "application/json" }
|
||||
$proj = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<foundry-account>/projects/<project>"
|
||||
$lang = "https://<lang-svc>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview"
|
||||
|
||||
# Path 2 — agent identity
|
||||
$body2 = @{ properties = @{
|
||||
category = "RemoteTool"; target = $lang
|
||||
authType = "AgenticIdentityToken"; audience = "https://cognitiveservices.azure.com"
|
||||
isSharedToAll = $false
|
||||
}} | ConvertTo-Json -Depth 5
|
||||
az rest --method PUT --headers "Content-Type=application/json" `
|
||||
--url "https://management.azure.com$proj/connections/lang-mcp-agent-id?api-version=2025-04-01-preview" `
|
||||
--body $body2
|
||||
```
|
||||
|
||||
Connection summary:
|
||||
|
||||
| Connection name (used by the toolbox) | `category` | `authType` | `audience` |
|
||||
|---|---|---|---|
|
||||
| `github-mcp-key` | `CustomKeys` | `CustomKeys` | n/a (key value carries `Authorization: Bearer <pat>`) |
|
||||
| `lang-mcp-agent-id` | `RemoteTool` | `AgenticIdentityToken` | `https://cognitiveservices.azure.com` |
|
||||
|
||||
Path #3 (`gitmcp.io`) needs no connection — the auth lives on the toolbox tool entry itself.
|
||||
|
||||
The `audience` value is the token resource identifier of the target service — for any Cognitive Services resource it is `https://cognitiveservices.azure.com`. For other Azure services consult [Agent identity — runtime token exchange](https://learn.microsoft.com/azure/foundry/agents/concepts/agent-identity#runtime-token-exchange).
|
||||
|
||||
### 3. Create the toolbox
|
||||
|
||||
In the Foundry portal → Tools → Add Toolbox. Name it `auth-paths-toolbox` (or whatever you prefer; export the name as `TOOLBOX_NAME`). Add three MCP tool entries:
|
||||
|
||||
| Tool `server_label` | `server_url` | Auth |
|
||||
|---|---|---|
|
||||
| `github_pat` | `https://api.githubcopilot.com/mcp` | `project_connection_id: github-mcp-key` |
|
||||
| `lang_agent` | Your Language service MCP URL | `project_connection_id: lang-mcp-agent-id` |
|
||||
| `gitmcp_inline` | `https://gitmcp.io/Azure/azure-rest-api-specs` | `authorization: "Bearer demo-only-not-real"` (no `project_connection_id`) |
|
||||
|
||||
Each entry should also carry:
|
||||
|
||||
- `require_approval: never` (this sample is focused on auth, not approval flows; see [`ToolCallingApprovalHostedAgentFixture.cs`](../../../../../tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolCallingApprovalHostedAgentFixture.cs) for that concern).
|
||||
- A tight `allowed_tools` list. GitHub MCP exposes ~50 tools; restrict to what you actually want the model to invoke. For example: `github_pat` → `["search_issues", "list_pull_requests"]`. **Every name in `allowed_tools` must match a real tool on the upstream server** — an unknown name (e.g., `get_issue`, which GitHub MCP does not expose) makes the whole source fail enumeration. See the partial-failure note below.
|
||||
|
||||
### Sidebar — what the toolbox-creation code looks like
|
||||
|
||||
This sample assumes the toolbox already exists; it does not provision one programmatically. For an end-to-end code example of toolbox creation from a publisher script (suitable for a CI/CD pipeline), see [`02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs`](../../../../02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs) — its `CreateSampleToolboxAsync` helper uses `AgentAdministrationClient.GetAgentToolboxes().CreateToolboxVersionAsync(...)` and is the canonical pattern.
|
||||
|
||||
## Run the agent
|
||||
|
||||
Set environment variables (or copy `.env.example` to `.env` and fill it in):
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
|
||||
$env:TOOLBOX_NAME = "auth-paths-toolbox"
|
||||
```
|
||||
|
||||
Locally, the `Foundry.Hosting` package reads `AZURE_AI_PROJECT_ENDPOINT` as a fallback when `FOUNDRY_PROJECT_ENDPOINT` is absent. In the hosted Foundry runtime, the platform auto-injects `FOUNDRY_PROJECT_ENDPOINT` and the package builds the toolbox proxy URL as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
|
||||
|
||||
Then sign in (`az login`) and start the server:
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
The server logs at `http://localhost:8088/`. In Development it also maps the per-agent OpenAI route shape (`MapDevTemporaryLocalAgentEndpoint()`), so the shared `SimpleAgent` REPL client can reach it through `AsAIAgent(agentEndpoint)` — the only supported way to consume a hosted Foundry agent. In a separate terminal:
|
||||
|
||||
**Against the local dev server** (point the client at localhost; the `{project}` segment is a wildcard the server ignores):
|
||||
|
||||
```powershell
|
||||
cd ../Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
**Against a deployed agent** (point the client at the real project endpoint and the deployed agent name):
|
||||
|
||||
```powershell
|
||||
cd ../Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
Either way the client derives the per-agent endpoint URL (`{AZURE_AI_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`) and consumes the agent via `AsAIAgent(agentEndpoint)`. Run `az login` first so the client can mint a bearer token.
|
||||
|
||||
> **Parallel-run warning**: `Hosted-Toolbox/` and other `Hosted-*` samples default to the same port (8088) and the same agent name slot. Always set a unique `AGENT_NAME` (this sample defaults to `hosted-toolbox-auth-paths-agent`) and stop other hosted samples before starting this one.
|
||||
|
||||
## Sample prompts
|
||||
|
||||
One per auth path so each tool gets exercised at least once:
|
||||
|
||||
```
|
||||
List the latest 3 issues in microsoft/agent-framework. # path #1 — GitHub MCP (key)
|
||||
Detect the language of "Bonjour le monde". # path #2 — Language MCP (agent identity)
|
||||
What's the latest API version for Microsoft.CognitiveServices? # path #3 — gitmcp.io (inline Authorization)
|
||||
```
|
||||
|
||||
## Troubleshooting / partial-failure semantics
|
||||
|
||||
`AddFoundryToolboxes` resolves the toolbox at startup by listing its tools via MCP `tools/list`. This enumeration is **all-or-nothing**: if *any* single tool source fails to enumerate, the Foundry toolbox proxy returns a top-level JSON-RPC error (`-32007`) instead of a partial list, the hosting package marks the toolbox startup as failed, `/readiness` returns 503, and *every* invoke against the agent returns **HTTP 424** — even for the auth paths that are configured correctly. So one misconfigured connection or one bad `allowed_tools` entry bricks the whole agent at startup, not just at tool-call time. Get each source enumerating cleanly before deploying. Symptoms per auth path:
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| **All invokes return HTTP 424 ("Failed Dependency")** | One or more tool sources failed `tools/list` at startup (see all-or-nothing note above). Common causes: an `allowed_tools` name that does not exist on the upstream server, or an Entra connection whose token is rejected. Reproduce by calling the toolbox `tools/list` directly with your own token — a `-32007` top-level error names the failing source. |
|
||||
| **HTTP 401 "audience is incorrect"** | The connection's `audience` field is missing or does not match the OAuth resource identifier the target service accepts. For Cognitive Services targets, set `audience: "https://cognitiveservices.azure.com"`. |
|
||||
| **HTTP 401 / 403 "principal does not have access"** | Path #1: PAT expired or scope insufficient. Path #2: the agent's instance identity is missing the required role on the target resource. |
|
||||
| **Container reports zero tools but startup succeeded** | `FoundryToolboxService.StartAsync` caches the `tools/list` result at startup. If a connection or RBAC grant changed after the container started, force a fresh container (re-deploy the agent version) — the cache won't pick up the change until then. |
|
||||
| **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. |
|
||||
| **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. |
|
||||
| **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. |
|
||||
|
||||
## Region and model compatibility
|
||||
|
||||
Foundry Toolboxes have region constraints; some tool types are limited to specific models. This sample defaults to `gpt-4o`, which works in all supported regions. For the full matrix, see the [Foundry tools compatibility matrix](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox#region-and-model-compatibility).
|
||||
|
||||
## Anti-pattern note for path #3
|
||||
|
||||
Inline `authorization` on a toolbox tool entry stores credentials **inside the toolbox definition**. There is no rotation, no per-user scoping, no secret-store integration. Use it only for:
|
||||
|
||||
- Public MCP servers that ignore the bearer (the `gitmcp.io` case demonstrated here).
|
||||
- Local development against a test MCP server with a throwaway token.
|
||||
|
||||
For everything else use `project_connection_id` and let the platform inject credentials.
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-toolbox-auth-paths
|
||||
displayName: "Hosted Toolbox - Authentication Paths"
|
||||
|
||||
description: >
|
||||
A hosted agent demonstrating three MCP-tool authentication paths in a single
|
||||
Foundry Toolbox: API key via project connection, Microsoft Entra agent
|
||||
identity, and inline Authorization
|
||||
(anti-pattern). The toolbox itself is
|
||||
provisioned out of band; see this sample's README for the portal walkthrough.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Foundry Toolbox
|
||||
- Authentication
|
||||
- MCP
|
||||
|
||||
template:
|
||||
name: hosted-toolbox-auth-paths
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: TOOLBOX_NAME
|
||||
value: "{{TOOLBOX_NAME}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: TOOLBOX_NAME
|
||||
type: string
|
||||
default: "auth-paths-toolbox"
|
||||
description: "Name of the Foundry Toolbox to load at runtime."
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4o
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
- kind: toolbox
|
||||
name: "{{TOOLBOX_NAME}}"
|
||||
tools: []
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-toolbox-auth-paths
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,27 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolbox MCP tools.
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
|
||||
//
|
||||
// Demonstrates how to register one or more Foundry toolboxes so the agent can
|
||||
// Demonstrates how to register one or more Foundry toolsets so the agent can
|
||||
// call tools provided by the Foundry platform's managed MCP proxy.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
|
||||
// - Azure AI Foundry project endpoint. The Foundry hosted
|
||||
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
|
||||
// set AZURE_AI_PROJECT_ENDPOINT.
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
|
||||
// (injected automatically by Foundry platform at runtime)
|
||||
//
|
||||
// Optional:
|
||||
// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolbox)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server (auto-injected in hosted runtime)
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server (auto-injected in hosted runtime)
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Additional Foundry-Features header flags (the mandatory
|
||||
// Toolboxes=V1Preview flag is always sent; this env var
|
||||
// appends additional flags if present).
|
||||
//
|
||||
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
|
||||
// per tools-integration-spec.md §2–§3.
|
||||
// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
@@ -34,13 +28,10 @@ using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
|
||||
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
|
||||
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-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolbox";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
|
||||
|
||||
// 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).
|
||||
@@ -54,12 +45,12 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolbox.
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolset.
|
||||
Use the available tools to answer user questions.
|
||||
If a tool is not available for a request, let the user know clearly.
|
||||
""",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
|
||||
description: "Hosted agent backed by Foundry Toolbox MCP tools");
|
||||
description: "Hosted agent backed by Foundry Toolset MCP tools");
|
||||
|
||||
// ── Build the host ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -70,8 +61,8 @@ builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolbox name must match a toolbox registered in your Foundry project.
|
||||
// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Hosted-Toolbox
|
||||
|
||||
A hosted Foundry agent that loads tools from a Foundry Toolbox via the AF Foundry hosting bridge.
|
||||
|
||||
The agent declares one `FoundryAITool.CreateHostedMcpToolbox(name)` marker; `AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that resolves the marker into the individual MCP tools the toolbox bundles, connecting to the Foundry Toolboxes MCP proxy at startup and discovering tools via `tools/list`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a Toolbox configured.
|
||||
- Azure CLI logged in (`az login`).
|
||||
- Set environment variables:
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers)
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`)
|
||||
- `TOOLBOX_NAME` (default `my-toolbox`)
|
||||
|
||||
The `Foundry.Hosting` package builds the toolbox proxy URL from `FOUNDRY_PROJECT_ENDPOINT` as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
## Related samples
|
||||
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — extends this pattern with a three-tool toolbox demonstrating different MCP-tool authentication paths (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
-28
@@ -98,34 +98,6 @@ Using the Azure Developer CLI:
|
||||
azd ai agent invoke --local "What skills do you have available?"
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-toolbox-mcp-skills && cd hosted-toolbox-mcp-skills
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-toolbox-mcp-skills
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-5
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## 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 `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
|
||||
|
||||
-28
@@ -121,34 +121,6 @@ User message
|
||||
|
||||
The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir triage-workflow && cd triage-workflow
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME triage-workflow
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative.
|
||||
|
||||
+2
-30
@@ -5,7 +5,7 @@ A hosted agent that demonstrates **multi-agent workflow orchestration**. Three t
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
@@ -22,7 +22,7 @@ Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
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=hosted-workflow-simple
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
@@ -104,34 +104,6 @@ Input text
|
||||
|
||||
Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-workflows && cd hosted-workflows
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-workflow-simple
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME hosted-workflow-simple
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative.
|
||||
|
||||
+10
-87
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -14,32 +13,24 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// <summary>
|
||||
/// An <see cref="DelegatingHandler"/> that:
|
||||
/// <list type="bullet">
|
||||
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://ai.azure.com/.default</c>) per request, per <c>tools-integration-spec.md</c> §4.</item>
|
||||
/// <item>Always injects the mandatory <c>Foundry-Features: Toolboxes=V1Preview</c> header per spec §2, merging any additional flags from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c>.</item>
|
||||
/// <item>Propagates W3C trace context (<c>traceparent</c>, <c>tracestate</c>, <c>baggage</c>) from <see cref="Activity.Current"/> per spec §6.3.</item>
|
||||
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
|
||||
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
|
||||
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
{
|
||||
private const int MaxRetries = 3;
|
||||
|
||||
// Per tools-integration-spec.md §4, the container authenticates to the Foundry Toolbox
|
||||
// proxy with a bearer token whose audience is https://ai.azure.com.
|
||||
private static readonly TokenRequestContext s_tokenContext =
|
||||
new(["https://ai.azure.com/.default"]);
|
||||
|
||||
// Per tools-integration-spec.md §2, every proxy request MUST include the
|
||||
// Foundry-Features: Toolboxes=V1Preview opt-in header while the service is in preview.
|
||||
private const string MandatoryFeatureFlag = "Toolboxes=V1Preview";
|
||||
new(["https://cognitiveservices.azure.com/.default"]);
|
||||
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly string? _additionalFeaturesHeaderValue;
|
||||
private readonly string? _featuresHeaderValue;
|
||||
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? additionalFeaturesHeaderValue)
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
|
||||
{
|
||||
this._credential = credential;
|
||||
this._additionalFeaturesHeaderValue = additionalFeaturesHeaderValue;
|
||||
this._featuresHeaderValue = featuresHeaderValue;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
@@ -52,9 +43,10 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue));
|
||||
|
||||
PropagateTraceContext(request);
|
||||
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
|
||||
}
|
||||
|
||||
// MaxRetries is the total number of attempts (not additional retries after the first).
|
||||
for (int attempt = 0; attempt < MaxRetries; attempt++)
|
||||
@@ -90,75 +82,6 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
throw new InvalidOperationException("Retry loop completed without returning a response.");
|
||||
}
|
||||
|
||||
// Returns "Toolboxes=V1Preview" when no override is set, or
|
||||
// "Toolboxes=V1Preview,<override-value>" when an override is set and doesn't already include it.
|
||||
internal static string BuildFeaturesHeaderValue(string? additional)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(additional))
|
||||
{
|
||||
return MandatoryFeatureFlag;
|
||||
}
|
||||
|
||||
// Avoid duplicating the mandatory flag if the override happens to already include it
|
||||
// (case-insensitive, ignore surrounding whitespace).
|
||||
foreach (var part in additional!.Split(','))
|
||||
{
|
||||
if (string.Equals(part.Trim(), MandatoryFeatureFlag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return additional;
|
||||
}
|
||||
}
|
||||
|
||||
return $"{MandatoryFeatureFlag},{additional}";
|
||||
}
|
||||
|
||||
// Per tools-integration-spec.md §6.3, propagate W3C trace context onto outbound requests.
|
||||
// Skip headers already set on the message (callers / inner handlers may override).
|
||||
private static void PropagateTraceContext(HttpRequestMessage request)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Headers.Contains("traceparent"))
|
||||
{
|
||||
var traceparent = activity.Id;
|
||||
if (!string.IsNullOrEmpty(traceparent))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("traceparent", traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
var traceState = activity.TraceStateString;
|
||||
if (!string.IsNullOrEmpty(traceState) && !request.Headers.Contains("tracestate"))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("tracestate", traceState);
|
||||
}
|
||||
|
||||
// Baggage is a comma-separated list of key=value pairs per the W3C Baggage spec.
|
||||
if (!request.Headers.Contains("baggage"))
|
||||
{
|
||||
string? baggageHeader = null;
|
||||
foreach (var pair in activity.Baggage)
|
||||
{
|
||||
if (pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}";
|
||||
baggageHeader = baggageHeader is null ? entry : $"{baggageHeader},{entry}";
|
||||
}
|
||||
|
||||
if (baggageHeader is not null)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("baggage", baggageHeader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(
|
||||
HttpRequestMessage original,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts <see cref="FoundryToolboxService.StartupStatus"/> to the AspNetCore
|
||||
/// HealthChecks pipeline so the <c>GET /readiness</c> probe (mapped by
|
||||
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>) reflects whether
|
||||
/// pre-registered toolbox connections are usable. Registered automatically by
|
||||
/// <see cref="FoundryHostingExtensions.AddFoundryToolboxes(IServiceCollection, string[])"/>
|
||||
/// and its overloads.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class FoundryToolboxHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly FoundryToolboxService _toolboxService;
|
||||
|
||||
public FoundryToolboxHealthCheck(FoundryToolboxService toolboxService)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(toolboxService);
|
||||
this._toolboxService = toolboxService;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
switch (this._toolboxService.StartupStatus)
|
||||
{
|
||||
case FoundryToolboxStartupStatus.Healthy:
|
||||
return Task.FromResult(HealthCheckResult.Healthy(
|
||||
description: $"Foundry toolbox: {this._toolboxService.Tools.Count} tool(s) available."));
|
||||
|
||||
case FoundryToolboxStartupStatus.NoEndpoint:
|
||||
return Task.FromResult(HealthCheckResult.Healthy(
|
||||
description: "Foundry toolbox: neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support disabled (local dev)."));
|
||||
|
||||
case FoundryToolboxStartupStatus.Pending:
|
||||
return Task.FromResult(new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: "Foundry toolbox: startup has not completed yet."));
|
||||
|
||||
case FoundryToolboxStartupStatus.Unhealthy:
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["failedToolboxes"] = this._toolboxService.FailedToolboxNames,
|
||||
};
|
||||
return Task.FromResult(new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: $"Foundry toolbox: {this._toolboxService.FailedToolboxNames.Count} pre-registered toolbox(es) failed to open at startup.",
|
||||
data: data));
|
||||
|
||||
default:
|
||||
return Task.FromResult(new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: $"Foundry toolbox: unknown startup status '{this._toolboxService.StartupStatus}'."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,14 @@ public sealed class FoundryToolboxOptions
|
||||
/// Gets the list of toolbox names to connect to at startup.
|
||||
/// Each name corresponds to a toolbox registered in the Foundry project.
|
||||
/// The platform proxy URL is constructed as:
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// per <c>tools-integration-spec.md</c> §2–§3.
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// </summary>
|
||||
public IList<string> ToolboxNames { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Toolboxes API version to use when constructing proxy URLs.
|
||||
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "v1";
|
||||
public string ApiVersion { get; set; } = "2025-05-01-preview";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
|
||||
@@ -37,9 +36,7 @@ public sealed class FoundryToolboxOptions
|
||||
public bool StrictMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For testing only: overrides the toolbox proxy base URL (skipping the
|
||||
/// <c>FOUNDRY_PROJECT_ENDPOINT</c>-derived default). When set, the proxy URL
|
||||
/// becomes <c>{EndpointOverride}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
|
||||
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
|
||||
/// Not part of the public API.
|
||||
/// </summary>
|
||||
internal string? EndpointOverride { get; set; }
|
||||
|
||||
@@ -24,13 +24,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The toolbox proxy base URL is derived from the platform-injected
|
||||
/// <c>FOUNDRY_PROJECT_ENDPOINT</c> environment variable per <c>tools-integration-spec.md</c>
|
||||
/// §2–§3. The per-toolbox proxy URL is constructed as
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent the service starts without error and
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
|
||||
/// no tools are registered, keeping the container healthy per spec §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -62,24 +56,6 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
/// </summary>
|
||||
public IReadOnlyList<AITool> Tools { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the startup status of the service. Reflects the outcome of pre-registered
|
||||
/// toolbox connections opened in <see cref="StartAsync"/>; lazy-opens triggered by
|
||||
/// per-request markers do not change this value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Consumed by <see cref="FoundryToolboxHealthCheck"/> to gate the
|
||||
/// <c>GET /readiness</c> probe so the Foundry hosted runtime does not start routing
|
||||
/// traffic to a container whose pre-registered toolbox failed to open at startup.
|
||||
/// </remarks>
|
||||
public FoundryToolboxStartupStatus StartupStatus { get; private set; } = FoundryToolboxStartupStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of pre-registered toolboxes that failed to open during
|
||||
/// <see cref="StartAsync"/>. Empty when startup was successful or has not run yet.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FailedToolboxNames { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
|
||||
/// </summary>
|
||||
@@ -99,24 +75,16 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
/// <inheritdoc/>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Per tools-integration-spec.md §2-§3, the container derives the toolbox proxy base
|
||||
// URL from the platform-injected FOUNDRY_PROJECT_ENDPOINT. The EndpointOverride
|
||||
// option exists for tests; AZURE_AI_PROJECT_ENDPOINT is honored as a local-dev
|
||||
// fallback to mirror the convention used by AF-repo samples.
|
||||
var projectEndpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
this._resolvedEndpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
|
||||
if (string.IsNullOrEmpty(projectEndpoint))
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled.");
|
||||
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
|
||||
this.Tools = [];
|
||||
this.StartupStatus = FoundryToolboxStartupStatus.NoEndpoint;
|
||||
return;
|
||||
}
|
||||
|
||||
this._resolvedEndpoint = projectEndpoint.TrimEnd('/');
|
||||
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
|
||||
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
|
||||
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
|
||||
@@ -125,12 +93,10 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
this._logger.LogInformation("No pre-registered toolbox names configured.");
|
||||
this.Tools = [];
|
||||
this.StartupStatus = FoundryToolboxStartupStatus.Healthy;
|
||||
return;
|
||||
}
|
||||
|
||||
var allTools = new List<AITool>();
|
||||
var failed = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var toolboxName in this._options.ToolboxNames)
|
||||
@@ -155,16 +121,10 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
|
||||
toolboxName);
|
||||
}
|
||||
|
||||
failed.Add(toolboxName);
|
||||
}
|
||||
}
|
||||
|
||||
this.Tools = allTools;
|
||||
this.FailedToolboxNames = failed;
|
||||
this.StartupStatus = failed.Count == 0
|
||||
? FoundryToolboxStartupStatus.Healthy
|
||||
: FoundryToolboxStartupStatus.Unhealthy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -205,7 +165,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
|
||||
}
|
||||
|
||||
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -232,7 +192,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="FoundryToolboxService"/> startup. Drives the
|
||||
/// <c>foundry-toolbox</c> health-check that gates the <c>GET /readiness</c> probe so the
|
||||
/// Foundry hosted runtime does not start routing traffic before pre-registered toolbox
|
||||
/// connections are confirmed open (per <c>container-image-spec.md</c> §3.1).
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public enum FoundryToolboxStartupStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="FoundryToolboxService.StartAsync"/> has not run yet. The health-check
|
||||
/// reports <c>Unhealthy</c> in this state so the platform waits for startup to
|
||||
/// complete before the first invocation.
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Startup completed and either every pre-registered toolbox opened successfully or
|
||||
/// no pre-registered toolboxes were configured. The health-check reports
|
||||
/// <c>Healthy</c>.
|
||||
/// </summary>
|
||||
Healthy = 1,
|
||||
|
||||
/// <summary>
|
||||
/// One or more pre-registered toolboxes failed to open during startup (including the
|
||||
/// partial case where some opened and some did not). The health-check reports
|
||||
/// <c>Unhealthy</c> and exposes the failed names in the <c>HealthCheckResult.Data</c>
|
||||
/// dictionary so operators can diagnose the failure without parsing log output.
|
||||
/// </summary>
|
||||
Unhealthy = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Neither the <c>FOUNDRY_PROJECT_ENDPOINT</c> nor the <c>AZURE_AI_PROJECT_ENDPOINT</c>
|
||||
/// environment variable is set. This is normal for local <c>dotnet run</c> flows and the
|
||||
/// health-check reports <c>Healthy</c> so the container is still routable; toolbox tools
|
||||
/// will simply not be available.
|
||||
/// </summary>
|
||||
NoEndpoint = 3,
|
||||
}
|
||||
+4
-19
@@ -44,33 +44,18 @@ public static class HostedFoundryMemoryProviderScopes
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, composing
|
||||
/// <see cref="HostedSessionContext.UserId"/> and <see cref="HostedSessionContext.ChatId"/> into a
|
||||
/// single delimiter-safe partition key. Use this when memories should be visible only to the same
|
||||
/// user within the same conversation.
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
|
||||
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
|
||||
/// only to the same user within the same conversation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both identity values are opaque strings that may contain any characters, including the <c>:</c>
|
||||
/// delimiter. To keep the composite key injective (so two distinct (user, chat) pairs can never
|
||||
/// collide), each part is escaped (<c>\</c> becomes <c>\\</c>, then <c>:</c> becomes <c>\:</c>) before
|
||||
/// being joined with a <c>::</c> separator.
|
||||
/// </remarks>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
|
||||
session =>
|
||||
{
|
||||
var ctx = GetRequiredHostedContext(session);
|
||||
return new FoundryMemoryProvider.State(
|
||||
new FoundryMemoryProviderScope($"{EscapeScopePart(ctx.UserId)}::{EscapeScopePart(ctx.ChatId)}"));
|
||||
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in a scope part so that distinct (user, chat) pairs produce distinct
|
||||
/// composite scope keys. Backslashes are escaped first (<c>\</c> becomes <c>\\</c>), then colons
|
||||
/// (<c>:</c> becomes <c>\:</c>), ensuring the <c>{user}::{chat}</c> format is unambiguous.
|
||||
/// </summary>
|
||||
private static string EscapeScopePart(string part) => part.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
|
||||
session?.GetHostedContext()
|
||||
?? throw new InvalidOperationException(
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectSharedRedaction>true</InjectSharedRedaction>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -7,12 +7,10 @@ using System.Runtime.CompilerServices;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
@@ -51,7 +49,6 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
@@ -87,7 +84,6 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
@@ -113,10 +109,10 @@ public static class FoundryHostingExtensions
|
||||
/// <para>
|
||||
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
|
||||
/// project. The proxy URL per toolbox is constructed as:
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1</c>
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// no tools are loaded (the container remains healthy per spec §2).
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -171,61 +167,12 @@ public static class FoundryHostingExtensions
|
||||
// multiple times will not invoke StartAsync twice on the same singleton.
|
||||
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
|
||||
|
||||
// Register the toolbox health check on the same /readiness pipeline that
|
||||
// MapFoundryResponses maps. This gates the Foundry hosted runtime's readiness
|
||||
// probe (per container-image-spec.md §3.1) on the outcome of the pre-registered
|
||||
// toolbox connections opened in FoundryToolboxService.StartAsync.
|
||||
// AddCheck<T>(name, ...) does NOT dedupe by name, so guard against duplicate
|
||||
// registration when AddFoundryToolboxes is called multiple times.
|
||||
const string HealthCheckName = "foundry-toolbox";
|
||||
services.AddHealthChecks();
|
||||
services.Configure<HealthCheckServiceOptions>(opts =>
|
||||
{
|
||||
foreach (var existing in opts.Registrations)
|
||||
{
|
||||
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
opts.Registrations.Add(new HealthCheckRegistration(
|
||||
name: HealthCheckName,
|
||||
factory: sp => ActivatorUtilities.CreateInstance<FoundryToolboxHealthCheck>(sp),
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: ["foundry", "toolbox", "readiness"]));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Also maps the Foundry-required <c>GET /readiness</c> health probe to
|
||||
/// <see cref="HealthCheckEndpointRouteBuilderExtensions.MapHealthChecks(IEndpointRouteBuilder, string)"/>
|
||||
/// when no <c>/readiness</c> route is already registered. This makes the package
|
||||
/// spec-compliant in the Foundry hosted runtime (which probes <c>/readiness</c>
|
||||
/// before accepting any invocation per <c>container-image-spec.md</c> §2; without
|
||||
/// it every request fails with HTTP 424 <c>session_not_ready</c>) regardless of the
|
||||
/// host spine the developer chose:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Tier 1/2</b> (<c>AgentHost.CreateBuilder</c>) — the Core SDK
|
||||
/// already maps <c>/readiness</c>. The duplicate-route guard below skips
|
||||
/// re-mapping it.</description></item>
|
||||
/// <item><description><b>Tier 3</b> (<c>WebApplication.CreateBuilder</c> +
|
||||
/// <c>AddFoundryResponses</c> + <c>MapFoundryResponses</c>) — the Core SDK
|
||||
/// does NOT map it. This call covers the gap automatically.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Developers can still opt out by registering their own <c>/readiness</c> route
|
||||
/// before calling <c>MapFoundryResponses</c>; the existing route is detected and
|
||||
/// preserved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
@@ -233,37 +180,9 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
MapReadinessIfMissing(endpoints);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <c>GET /readiness</c> to the AspNetCore HealthChecks pipeline only when no
|
||||
/// route already serves that path. The duplicate guard scans
|
||||
/// <see cref="EndpointDataSource"/> entries by route pattern, which catches both the
|
||||
/// SDK-mapped <c>MapHealthChecks("/readiness")</c> path used by
|
||||
/// <c>AgentHostBuilder</c> and any user-registered <c>app.MapGet("/readiness", ...)</c>
|
||||
/// route. Idempotent across multiple <c>MapFoundryResponses</c> invocations.
|
||||
/// </summary>
|
||||
private static void MapReadinessIfMissing(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
const string ReadinessPath = "/readiness";
|
||||
|
||||
foreach (var dataSource in endpoints.DataSources)
|
||||
{
|
||||
foreach (var endpoint in dataSource.Endpoints)
|
||||
{
|
||||
if (endpoint is RouteEndpoint route &&
|
||||
string.Equals(route.RoutePattern.RawText, ReadinessPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoints.MapHealthChecks(ReadinessPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ActivitySource name for the Responses hosting pipeline.
|
||||
/// </summary>
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI.GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace GitHub.Copilot;
|
||||
namespace GitHub.Copilot.SDK;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="CopilotClient"/>
|
||||
|
||||
@@ -9,7 +9,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
|
||||
// Subscribe to session events
|
||||
using IDisposable subscription = copilotSession.On<SessionEvent>(evt =>
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string prompt = string.Join("\n", messages.Select(m => m.Text));
|
||||
|
||||
// Handle DataContent as attachments
|
||||
(List<AttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -262,7 +262,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (this._copilotClient.State != ConnectionState.Connected)
|
||||
{
|
||||
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ResumeSessionConfig CreateResumeConfig()
|
||||
@@ -272,18 +275,36 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance
|
||||
/// with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// with <see cref="SessionConfig.Streaming"/> set to <c>true</c>.
|
||||
/// </summary>
|
||||
internal static SessionConfig CopySessionConfig(SessionConfig source)
|
||||
{
|
||||
SessionConfig copy = source.Clone();
|
||||
copy.Streaming = true;
|
||||
return copy;
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = source.Model,
|
||||
ReasoningEffort = source.ReasoningEffort,
|
||||
Tools = source.Tools,
|
||||
SystemMessage = source.SystemMessage,
|
||||
AvailableTools = source.AvailableTools,
|
||||
ExcludedTools = source.ExcludedTools,
|
||||
Provider = source.Provider,
|
||||
OnPermissionRequest = source.OnPermissionRequest,
|
||||
OnUserInputRequest = source.OnUserInputRequest,
|
||||
Hooks = source.Hooks,
|
||||
WorkingDirectory = source.WorkingDirectory,
|
||||
ConfigDir = source.ConfigDir,
|
||||
McpServers = source.McpServers,
|
||||
CustomAgents = source.CustomAgents,
|
||||
SkillDirectories = source.SkillDirectories,
|
||||
DisabledSkills = source.DisabledSkills,
|
||||
InfiniteSessions = source.InfiniteSessions,
|
||||
Streaming = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
|
||||
/// <see cref="ResumeSessionConfig"/> with <see cref="ResumeSessionConfig.Streaming"/> set to <c>true</c>.
|
||||
/// </summary>
|
||||
internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source)
|
||||
{
|
||||
@@ -300,7 +321,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
OnUserInputRequest = source?.OnUserInputRequest,
|
||||
Hooks = source?.Hooks,
|
||||
WorkingDirectory = source?.WorkingDirectory,
|
||||
ConfigDirectory = source?.ConfigDirectory,
|
||||
ConfigDir = source?.ConfigDir,
|
||||
McpServers = source?.McpServers,
|
||||
CustomAgents = source?.CustomAgents,
|
||||
SkillDirectories = source?.SkillDirectories,
|
||||
@@ -373,10 +394,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
AdditionalPropertiesDictionary<long>? additionalCounts = null;
|
||||
|
||||
if (usageEvent.Data.CacheWriteTokens is long cacheWriteTokens)
|
||||
if (usageEvent.Data.CacheWriteTokens is double cacheWriteTokens)
|
||||
{
|
||||
additionalCounts ??= [];
|
||||
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = cacheWriteTokens;
|
||||
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = (long)cacheWriteTokens;
|
||||
}
|
||||
|
||||
if (usageEvent.Data.Cost is double cost)
|
||||
@@ -385,10 +406,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
additionalCounts[nameof(AssistantUsageData.Cost)] = (long)cost;
|
||||
}
|
||||
|
||||
if (usageEvent.Data.Duration is TimeSpan duration)
|
||||
if (usageEvent.Data.Duration is double duration)
|
||||
{
|
||||
additionalCounts ??= [];
|
||||
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration.TotalMilliseconds;
|
||||
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration;
|
||||
}
|
||||
|
||||
return additionalCounts;
|
||||
@@ -411,7 +432,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
|
||||
private static SessionConfig? GetSessionConfig(IList<AITool>? tools, string? instructions)
|
||||
{
|
||||
List<AIFunctionDeclaration>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunctionDeclaration>().ToList() : null;
|
||||
List<AIFunction>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunction>().ToList() : null;
|
||||
SystemMessageConfig? systemMessage = instructions is not null ? new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = instructions } : null;
|
||||
|
||||
if (mappedTools is null && systemMessage is null)
|
||||
@@ -422,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
|
||||
}
|
||||
|
||||
private static async Task<(List<AttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<AttachmentFile>? attachments = null;
|
||||
List<UserMessageAttachmentFile>? attachments = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
@@ -440,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new AttachmentFile
|
||||
attachments.Add(new UserMessageAttachmentFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath)
|
||||
|
||||
+1
-50
@@ -1,10 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -32,52 +31,4 @@
|
||||
<Description>Provides Microsoft Agent Framework support for GitHub Copilot SDK.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
buildTransitive bridge for GitHub.Copilot.SDK's CLI binary-download targets.
|
||||
|
||||
GitHub.Copilot.SDK ships its CLI download targets under build/, which NuGet
|
||||
only auto-imports for projects with a DIRECT PackageReference to the SDK.
|
||||
Consumers of this package (who reference Microsoft.Agents.AI.GitHub.Copilot
|
||||
instead of GitHub.Copilot.SDK directly) would otherwise get only the managed
|
||||
adapter .dll, no copilot.exe in their output, and a runtime failure at the
|
||||
first RunAsync call.
|
||||
|
||||
The targets file in buildTransitive/ is static and contains all of the
|
||||
Condition / Import logic. The .props file generated here just bakes the
|
||||
SDK version this package was built against into a dedicated property
|
||||
($(_MicrosoftAgentsAICopilotSdkPackagedVersion)) — no Condition or
|
||||
inner $(...) reference in the generated file, so we avoid any MSBuild
|
||||
escape gymnastics during string construction. The static targets file
|
||||
then defaults $(_MicrosoftAgentsAICopilotSdkVersion) to the packaged
|
||||
version unless the consumer overrides it.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.targets" Pack="true" PackagePath="buildTransitive\" />
|
||||
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props" Pack="true" PackagePath="buildTransitive\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="_GenerateBuildTransitiveProps" BeforeTargets="Build;Pack;_GetPackageFiles">
|
||||
<ItemGroup>
|
||||
<_CopilotSdkPackageVersion Include="@(PackageVersion)" Condition="'%(Identity)' == 'GitHub.Copilot.SDK'" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<_CopilotSdkResolvedVersion>@(_CopilotSdkPackageVersion->'%(Version)')</_CopilotSdkResolvedVersion>
|
||||
</PropertyGroup>
|
||||
<Error Condition="'$(_CopilotSdkResolvedVersion)' == ''"
|
||||
Text="Could not resolve GitHub.Copilot.SDK version from PackageVersion items. Ensure the central package version is declared in Directory.Packages.props." />
|
||||
<PropertyGroup>
|
||||
<_BuildTransitivePropsContent>
|
||||
<![CDATA[<Project>
|
||||
<PropertyGroup>
|
||||
<_MicrosoftAgentsAICopilotSdkPackagedVersion>$(_CopilotSdkResolvedVersion)</_MicrosoftAgentsAICopilotSdkPackagedVersion>
|
||||
</PropertyGroup>
|
||||
</Project>]]>
|
||||
</_BuildTransitivePropsContent>
|
||||
</PropertyGroup>
|
||||
<WriteLinesToFile File="$(MSBuildThisFileDirectory)buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props"
|
||||
Lines="$(_BuildTransitivePropsContent)"
|
||||
Overwrite="true"
|
||||
WriteOnlyWhenDifferent="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Auto-generated at pack time by _GenerateBuildTransitiveProps in the csproj.
|
||||
Microsoft.Agents.AI.GitHub.Copilot.props
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
<Project>
|
||||
<!--
|
||||
Bridge GitHub.Copilot.SDK's build/ targets to transitive consumers.
|
||||
|
||||
GitHub.Copilot.SDK ships its CLI download / binary-copy MSBuild targets under
|
||||
build/, which means they only auto-import for projects with a DIRECT
|
||||
PackageReference to GitHub.Copilot.SDK. Consumers of this package
|
||||
(Microsoft.Agents.AI.GitHub.Copilot) would otherwise get only the managed
|
||||
adapter .dll, no copilot CLI binary in their output, and the SDK would fail
|
||||
at runtime with:
|
||||
|
||||
Copilot CLI not found at 'bin/{config}/{tfm}/runtimes/{rid}/native/copilot.exe'
|
||||
|
||||
This file ships under buildTransitive/ so NuGet auto-imports it for every
|
||||
transitive consumer, locates the SDK in the NuGet package cache, and imports
|
||||
the SDK's build/ targets so the CLI binary gets downloaded and copied to the
|
||||
consumer's output as expected.
|
||||
|
||||
The companion .props file is generated at this package's pack time and sets
|
||||
$(_MicrosoftAgentsAICopilotSdkPackagedVersion) to the SDK version this
|
||||
package was built against. The Condition below uses that as the default for
|
||||
$(_MicrosoftAgentsAICopilotSdkVersion), so consumers may override the SDK
|
||||
version path by setting $(_MicrosoftAgentsAICopilotSdkVersion) before this
|
||||
file is imported. Consumers may also opt out of the binary download via the
|
||||
SDK's own $(CopilotSkipCliDownload)=true, which the SDK targets honor.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<_MicrosoftAgentsAICopilotSdkVersion Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' == ''">$(_MicrosoftAgentsAICopilotSdkPackagedVersion)</_MicrosoftAgentsAICopilotSdkVersion>
|
||||
<_MicrosoftAgentsAICopilotSdkTargetsPath Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' != ''">$([System.IO.Path]::Combine('$(NuGetPackageRoot)', 'github.copilot.sdk', '$(_MicrosoftAgentsAICopilotSdkVersion)', 'build', 'GitHub.Copilot.SDK.targets'))</_MicrosoftAgentsAICopilotSdkTargetsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(_MicrosoftAgentsAICopilotSdkTargetsPath)"
|
||||
Condition="'$(_MicrosoftAgentsAICopilotSdkTargetsPath)' != '' And Exists('$(_MicrosoftAgentsAICopilotSdkTargetsPath)')" />
|
||||
</Project>
|
||||
@@ -16,16 +16,23 @@ public static class ChatClientHarnessExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="HarnessAgent"/> that wraps this <see cref="IChatClient"/> with a pre-configured
|
||||
/// pipeline including function invocation, per-service-call chat history persistence, optional in-loop compaction, and a rich set
|
||||
/// of default context providers and agent decorators.
|
||||
/// pipeline including function invocation, per-service-call chat history persistence, and in-loop compaction.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">
|
||||
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
|
||||
/// </param>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the agent, including instructions override, tools,
|
||||
/// additional context providers, chat history provider, and compaction settings.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings with compaction disabled.
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
@@ -36,8 +43,10 @@ public static class ChatClientHarnessExtensions
|
||||
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
||||
public static HarnessAgent AsHarnessAgent(
|
||||
this IChatClient chatClient,
|
||||
int maxContextWindowTokens,
|
||||
int maxOutputTokens,
|
||||
HarnessAgentOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null) =>
|
||||
new(chatClient, options, loggerFactory, services);
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
}
|
||||
|
||||
@@ -18,65 +18,50 @@ namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A pre-configured <see cref="DelegatingAIAgent"/> that wraps a <see cref="ChatClientAgent"/> with
|
||||
/// function invocation, per-service-call chat history persistence, optional in-loop compaction, and a rich set
|
||||
/// function invocation, per-service-call chat history persistence, in-loop compaction, and a rich set
|
||||
/// of default context providers and agent decorators.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="HarnessAgent"/> provides an opinionated, batteries-included agent suitable for
|
||||
/// interactive agentic scenarios such as research, coding, data analysis, and general task automation.
|
||||
/// It assembles a full pipeline from a caller-supplied <see cref="IChatClient"/> so that callers
|
||||
/// only need to configure the parts they want to customize.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Chat client pipeline (inner to outer):</strong>
|
||||
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
|
||||
/// <list type="number">
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation with configurable iteration limits.</description></item>
|
||||
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream (e.g., for user interrupts).</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop, enabling crash recovery and history inspection.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window. Only included when <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> and <see cref="HarnessAgentOptions.MaxOutputTokens"/> are both provided.</description></item>
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
|
||||
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Context providers (each enabled by default, individually disableable via <see cref="HarnessAgentOptions"/>):</strong>
|
||||
/// By default, the following context providers are included (each can be disabled via <see cref="HarnessAgentOptions"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TodoProvider"/> — persistent todo list that the agent uses to track multi-step plans. Disable with <see cref="HarnessAgentOptions.DisableTodoProvider"/>.</description></item>
|
||||
/// <item><description><see cref="AgentModeProvider"/> — mode tracking (e.g., "plan" vs "execute") that the agent uses to structure its work. Disable with <see cref="HarnessAgentOptions.DisableAgentModeProvider"/>.</description></item>
|
||||
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory allowing the agent to persist notes and artifacts across turns. Disable with <see cref="HarnessAgentOptions.DisableFileMemory"/>.</description></item>
|
||||
/// <item><description><see cref="FileAccessProvider"/> — shared file access providing read/write tools for a working directory. Disable with <see cref="HarnessAgentOptions.DisableFileAccess"/>.</description></item>
|
||||
/// <item><description><see cref="AgentSkillsProvider"/> — discovers and loads skill definitions from the file system, enabling dynamic tool sets. Disable with <see cref="HarnessAgentOptions.DisableAgentSkillsProvider"/>.</description></item>
|
||||
/// <item><description><see cref="TodoProvider"/> — todo list management.</description></item>
|
||||
/// <item><description><see cref="AgentModeProvider"/> — agent mode tracking (plan/execute).</description></item>
|
||||
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory.</description></item>
|
||||
/// <item><description><see cref="FileAccessProvider"/> — shared file access.</description></item>
|
||||
/// <item><description><see cref="AgentSkillsProvider"/> — skill discovery and loading.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Optional context providers (enabled via <see cref="HarnessAgentOptions"/>):</strong>
|
||||
/// The agent is also wrapped with the following decorators by default (each can be disabled):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="BackgroundAgentsProvider"/> — enables delegation to background agents for parallel work. Enable by setting <see cref="HarnessAgentOptions.BackgroundAgents"/>.</description></item>
|
||||
/// <item><description><c>ShellEnvironmentProvider</c> — injects OS/shell/CWD information and a shell execution tool. Enable by setting <c>HarnessAgentOptions.ShellExecutor</c> (.NET only).</description></item>
|
||||
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules.</description></item>
|
||||
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Agent decorators (each enabled by default, individually disableable):</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules enabling safe unattended execution. Disable with <see cref="HarnessAgentOptions.DisableToolApproval"/>.</description></item>
|
||||
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation following semantic conventions for generative AI. Disable with <see cref="HarnessAgentOptions.DisableOpenTelemetry"/>.</description></item>
|
||||
/// </list>
|
||||
/// A <see cref="HostedWebSearchTool"/> is added to the chat options by default (can be disabled via
|
||||
/// <see cref="HarnessAgentOptions.DisableWebSearch"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Default tools:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="HostedWebSearchTool"/> — a hosted web search tool added to chat options by default. Disable with <see cref="HarnessAgentOptions.DisableWebSearch"/>.</description></item>
|
||||
/// </list>
|
||||
/// The underlying <see cref="ChatClientAgent"/> is configured with
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
|
||||
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
|
||||
/// to match the manually-assembled pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Chat history:</strong> When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied,
|
||||
/// the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>. If compaction is enabled, the provider
|
||||
/// is configured with a compaction-based chat reducer to keep in-memory history bounded. Otherwise, no reducer
|
||||
/// is applied.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Default instructions:</strong> The agent includes built-in system instructions (<see cref="DefaultInstructions"/>)
|
||||
/// that guide general tool usage and reasoning patterns. These can be overridden via <see cref="HarnessAgentOptions.HarnessInstructions"/>
|
||||
/// and combined with agent-specific instructions via <see cref="ChatOptions.Instructions"/>.
|
||||
/// When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied, the agent defaults to an
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> whose chat reducer applies the same compaction strategy,
|
||||
/// keeping in-memory history from growing unboundedly across sessions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
@@ -105,13 +90,21 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// </summary>
|
||||
/// <param name="chatClient">
|
||||
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
|
||||
/// The agent wraps this client in a function-invocation and per-service-call persistence pipeline.
|
||||
/// When compaction is enabled via <paramref name="options"/>, a compaction decorator is also added.
|
||||
/// The agent wraps this client in a function-invocation, per-service-call persistence,
|
||||
/// and compaction pipeline automatically.
|
||||
/// </param>
|
||||
/// <param name="maxContextWindowTokens">
|
||||
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy.
|
||||
/// </param>
|
||||
/// <param name="maxOutputTokens">
|
||||
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// Used to configure the compaction strategy and to limit the model's output.
|
||||
/// </param>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the agent, including instructions override, tools,
|
||||
/// additional context providers, chat history provider, and compaction settings.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings with compaction disabled.
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
@@ -123,22 +116,23 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> is not positive, or
|
||||
/// <see cref="HarnessAgentOptions.MaxOutputTokens"/> is negative or greater than or equal to
|
||||
/// <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> (when both are provided).
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||
/// </exception>
|
||||
public HarnessAgent(IChatClient chatClient, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||
: base(BuildAgent(
|
||||
Throw.IfNull(chatClient),
|
||||
maxContextWindowTokens,
|
||||
maxOutputTokens,
|
||||
options,
|
||||
loggerFactory,
|
||||
services))
|
||||
{
|
||||
}
|
||||
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, options, loggerFactory, services);
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
|
||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||
|
||||
@@ -155,35 +149,17 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
return builder.Build(services);
|
||||
}
|
||||
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
// Determine compaction strategy:
|
||||
// 1. DisableCompaction = true → no compaction
|
||||
// 2. Custom CompactionStrategy provided → use it (ignore token params)
|
||||
// 3. Both token params provided → build default ContextWindowCompactionStrategy
|
||||
// 4. Otherwise → no compaction
|
||||
CompactionStrategy? compactionStrategy = null;
|
||||
if (options?.DisableCompaction is not true)
|
||||
{
|
||||
if (options?.CompactionStrategy is CompactionStrategy customStrategy)
|
||||
{
|
||||
compactionStrategy = customStrategy;
|
||||
}
|
||||
else if (options?.MaxContextWindowTokens is int maxCtx && options?.MaxOutputTokens is int maxOut)
|
||||
{
|
||||
compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxCtx,
|
||||
maxOutputTokens: maxOut);
|
||||
}
|
||||
}
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxContextWindowTokens,
|
||||
maxOutputTokens: maxOutputTokens);
|
||||
|
||||
ChatHistoryProvider chatHistoryProvider = options?.ChatHistoryProvider
|
||||
?? (compactionStrategy is not null
|
||||
? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
})
|
||||
: new InMemoryChatHistoryProvider());
|
||||
?? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
});
|
||||
|
||||
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
|
||||
string? agentInstructions = options?.ChatOptions?.Instructions;
|
||||
@@ -196,34 +172,20 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
|
||||
};
|
||||
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, options?.MaxOutputTokens);
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
||||
|
||||
CompactionProvider? compactionProvider = compactionStrategy is not null
|
||||
? new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory)
|
||||
: null;
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
|
||||
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
|
||||
|
||||
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
|
||||
|
||||
if (options?.DisableNonApprovalRequiredFunctionBypassing is not true)
|
||||
{
|
||||
chatClientBuilder.UseNonApprovalRequiredFunctionBypassing();
|
||||
}
|
||||
|
||||
ChatClientBuilder pipeline = chatClientBuilder
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||
: null)
|
||||
.UseMessageInjection()
|
||||
.UsePerServiceCallChatHistoryPersistence();
|
||||
|
||||
if (compactionProvider is not null)
|
||||
{
|
||||
pipeline = pipeline.UseAIContextProviders(compactionProvider);
|
||||
}
|
||||
|
||||
return pipeline
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(compactionProvider)
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = options?.Id,
|
||||
@@ -241,15 +203,11 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
services);
|
||||
}
|
||||
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int? maxOutputTokens)
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
||||
{
|
||||
ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions();
|
||||
result.Instructions = instructions;
|
||||
|
||||
if (maxOutputTokens.HasValue)
|
||||
{
|
||||
result.MaxOutputTokens ??= maxOutputTokens.Value;
|
||||
}
|
||||
result.MaxOutputTokens ??= maxOutputTokens;
|
||||
|
||||
if (options?.DisableWebSearch is not true)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
@@ -32,68 +31,6 @@ public sealed class HarnessAgentOptions
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When both <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/> are provided (and no
|
||||
/// custom <see cref="CompactionStrategy"/> is set), a default <see cref="ContextWindowCompactionStrategy"/>
|
||||
/// is constructed from these values to prevent function-invocation loops from overflowing the context window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ignored when <see cref="CompactionStrategy"/> is provided or when <see cref="DisableCompaction"/> is
|
||||
/// <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? MaxContextWindowTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, this value is used as the default for <see cref="ChatOptions"/>.<see cref="ChatOptions.MaxOutputTokens"/>
|
||||
/// when not explicitly configured.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For compaction purposes, this value is used together with <see cref="MaxContextWindowTokens"/> to construct a
|
||||
/// default <see cref="ContextWindowCompactionStrategy"/> — but only when no custom <see cref="CompactionStrategy"/>
|
||||
/// is provided and <see cref="DisableCompaction"/> is <see langword="false"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a custom <see cref="Compaction.CompactionStrategy"/> to use for in-loop context-window compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When provided, this strategy is used directly and <see cref="MaxContextWindowTokens"/> and
|
||||
/// <see cref="MaxOutputTokens"/> are ignored for compaction purposes (<see cref="MaxOutputTokens"/> is still
|
||||
/// used as the default for <see cref="ChatOptions"/>.<see cref="ChatOptions.MaxOutputTokens"/> if set).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see langword="null"/> and both <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/>
|
||||
/// are provided, a default <see cref="ContextWindowCompactionStrategy"/> is constructed from those values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property is ignored when <see cref="DisableCompaction"/> is <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public CompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether in-loop compaction is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="true"/>, compaction is disabled regardless of <see cref="CompactionStrategy"/>,
|
||||
/// <see cref="MaxContextWindowTokens"/>, or <see cref="MaxOutputTokens"/> settings. No
|
||||
/// <see cref="CompactionProvider"/> is added to the chat client pipeline, and the default
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> is configured without a chat reducer.
|
||||
/// </remarks>
|
||||
public bool DisableCompaction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional chat options such as tools for the agent to use.
|
||||
/// </summary>
|
||||
@@ -131,9 +68,9 @@ public sealed class HarnessAgentOptions
|
||||
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// If <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/> are both provided,
|
||||
/// the default provider is configured with a compaction-based chat reducer; otherwise, no reducer is applied.
|
||||
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>
|
||||
/// configured with a compaction-based chat reducer derived from the <c>maxContextWindowTokens</c>
|
||||
/// and <c>maxOutputTokens</c> constructor parameters of <see cref="HarnessAgent"/>.
|
||||
/// </remarks>
|
||||
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
|
||||
|
||||
@@ -173,20 +110,6 @@ public sealed class HarnessAgentOptions
|
||||
/// </remarks>
|
||||
public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether bypassing of approval requests for tools that do not
|
||||
/// require approval is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
|
||||
/// added by <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/> above the
|
||||
/// function invocation middleware.
|
||||
/// This stores automatically approved function calls for tools that do not require approval in the session
|
||||
/// state when they are returned alongside tools that do, so that only tools that truly require human
|
||||
/// approval are surfaced to the caller.
|
||||
/// </remarks>
|
||||
public bool DisableNonApprovalRequiredFunctionBypassing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
|
||||
/// </summary>
|
||||
|
||||
+7
-25
@@ -21,18 +21,6 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
|
||||
/// must uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
|
||||
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
|
||||
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
|
||||
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
|
||||
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
|
||||
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
|
||||
/// provider maps a different claim).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
@@ -72,24 +60,18 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the value of the
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the HTTP
|
||||
/// context is unavailable, the user is not authenticated, or the claim is not present.
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method only reads claims from an authenticated principal: if the current request has no
|
||||
/// authenticated user, it returns <see langword="null"/> rather than trusting claims on an
|
||||
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
|
||||
/// multiple claims of the specified type exist, the first match is returned.
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return new ValueTask<string?>((string?)null);
|
||||
}
|
||||
|
||||
Claim? claim = user?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
|
||||
+6
-19
@@ -14,30 +14,17 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimTypes.NameIdentifier"/>, which corresponds to a stable, unique
|
||||
/// subject identifier for the authenticated principal. For OpenID Connect tokens (including those
|
||||
/// issued by Microsoft Entra ID), this is typically populated from the <c>sub</c> claim via the
|
||||
/// default JWT inbound claim mapping. Note that <c>sub</c> is distinct from Entra's object
|
||||
/// identifier (<c>oid</c>) claim; if you require the <c>oid</c> claim, or your provider does not map
|
||||
/// a unique identifier onto <see cref="ClaimTypes.NameIdentifier"/>, override <see cref="ClaimType"/>
|
||||
/// with the appropriate claim type.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> The configured claim must uniquely identify the principal
|
||||
/// within the served population. Display names (<see cref="ClaimsIdentity.DefaultNameClaimType"/>
|
||||
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
|
||||
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
|
||||
/// across all callers. Two distinct principals that share the same value for a non-unique claim
|
||||
/// would receive the same session-isolation key and could read or overwrite one another's
|
||||
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A composite of tenant and subject identifiers — required for multi-tenant hosts where the subject is only unique per tenant</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider, provided they are unique and stable</description></item>
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimTypes.NameIdentifier;
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -20,28 +19,8 @@ public static class ServiceCollectionExtensions
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <paramref name="options"/> is not supplied, the isolation key is derived from the
|
||||
/// <see cref="ClaimTypes.NameIdentifier"/> claim, a stable unique subject identifier. For OpenID
|
||||
/// Connect tokens (including Microsoft Entra ID), this is typically mapped from the <c>sub</c> claim
|
||||
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
|
||||
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
|
||||
/// such as Entra's <c>oid</c>) should override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
|
||||
/// absent, which causes strict-mode session stores to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security warning:</strong> If you override
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
|
||||
/// uniquely identify the principal within the served population. Display names, usernames, email
|
||||
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
|
||||
/// the host can prove their uniqueness across all callers, because distinct principals that share the
|
||||
/// same claim value would receive the same isolation key and could access one another's sessions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview;
|
||||
@@ -20,7 +16,6 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
{
|
||||
private readonly IChannelHandler _channelHandler;
|
||||
private readonly IPurviewClient _purviewClient;
|
||||
private readonly ICacheProvider _cacheProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -28,14 +23,12 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
/// </summary>
|
||||
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
|
||||
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
|
||||
/// <param name="cacheProvider">The cache provider used to store protection scopes results.</param>
|
||||
/// <param name="logger">The logger used to log information about background jobs.</param>
|
||||
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings)
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
|
||||
{
|
||||
this._channelHandler = channelHandler;
|
||||
this._purviewClient = purviewClient;
|
||||
this._cacheProvider = cacheProvider;
|
||||
this._logger = logger;
|
||||
|
||||
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
|
||||
@@ -74,28 +67,6 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
break;
|
||||
case ContentActivityJob contentActivityJob:
|
||||
_ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case ScopeRetrievalJob scopeRetrievalJob:
|
||||
try
|
||||
{
|
||||
ProtectionScopesResponse response = await this._purviewClient.GetProtectionScopesAsync(scopeRetrievalJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(scopeRetrievalJob.CacheKey, response, CancellationToken.None).ConfigureAwait(false);
|
||||
(bool shouldProcess, List<DlpActionInfo> _, ExecutionMode _) = ScopedContentProcessor.CheckApplicableScopes(scopeRetrievalJob.ProcessContentRequest, response);
|
||||
if (!shouldProcess)
|
||||
{
|
||||
ProcessContentRequest pcRequest = scopeRetrievalJob.ProcessContentRequest;
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
this._channelHandler.QueueJob(new ContentActivityJob(caRequest));
|
||||
}
|
||||
}
|
||||
catch (PurviewPaymentRequiredException ex)
|
||||
{
|
||||
await this._cacheProvider.SetAsync(
|
||||
new PaymentRequiredCacheKey(scopeRetrievalJob.Request.TenantId),
|
||||
new PaymentRequiredCacheEntry(ex.Message),
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Cached tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheEntry"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The payment required error message.</param>
|
||||
public PaymentRequiredCacheEntry(string? message)
|
||||
{
|
||||
this.Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment required error message.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// A cache key for tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheKey"/>.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The id of the tenant.</param>
|
||||
public PaymentRequiredCacheKey(string tenantId)
|
||||
{
|
||||
this.TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The id of the tenant.
|
||||
/// </summary>
|
||||
public string TenantId { get; set; }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Class representing a job that refreshes the protection scopes cache in the background.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the
|
||||
/// foreground ProcessContent call.
|
||||
/// </remarks>
|
||||
internal sealed class ScopeRetrievalJob : BackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScopeRetrievalJob"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">The protection scopes request to send to Purview.</param>
|
||||
/// <param name="cacheKey">The cache key used to store the response.</param>
|
||||
/// <param name="processContentRequest">The original process content request that triggered scope retrieval.</param>
|
||||
public ScopeRetrievalJob(ProtectionScopesRequest request, ProtectionScopesCacheKey cacheKey, ProcessContentRequest processContentRequest)
|
||||
{
|
||||
this.Request = request;
|
||||
this.CacheKey = cacheKey;
|
||||
this.ProcessContentRequest = processContentRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the protection scopes request.
|
||||
/// </summary>
|
||||
public ProtectionScopesRequest Request { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache key used to store the response.
|
||||
/// </summary>
|
||||
public ProtectionScopesCacheKey CacheKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original process content request that triggered scope retrieval.
|
||||
/// </summary>
|
||||
public ProcessContentRequest ProcessContentRequest { get; }
|
||||
}
|
||||
@@ -53,10 +53,4 @@ internal sealed class ProcessContentRequest
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal string? ScopeIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the ProcessContent request should ask the service for inline evaluation.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal bool ProcessInline { get; set; }
|
||||
}
|
||||
|
||||
@@ -130,11 +130,6 @@ internal sealed class PurviewClient : IPurviewClient
|
||||
message.Headers.Add("If-None-Match", request.ScopeIdentifier);
|
||||
}
|
||||
|
||||
if (request.ProcessInline)
|
||||
{
|
||||
message.Headers.Add("Prefer", "evaluateInline");
|
||||
}
|
||||
|
||||
string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest)));
|
||||
message.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ The policy logic is identical; the only difference is the hook point in the pipe
|
||||
|
||||
The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user.
|
||||
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. When a lookup is not cached, the middleware will refresh it in a background worker so the foreground ProcessContent request does not have to wait.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. Payment Required responses from background scope lookups are cached at the tenant level so subsequent requests for the tenant short-circuit.
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit.
|
||||
|
||||
## Exceptions
|
||||
| Exception | Scenario |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
@@ -194,60 +193,43 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId);
|
||||
|
||||
PaymentRequiredCacheEntry? cachedPaymentRequired = await this._cacheProvider.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
new PaymentRequiredCacheKey(pcRequest.TenantId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (cachedPaymentRequired != null)
|
||||
{
|
||||
throw new PurviewPaymentRequiredException(cachedPaymentRequired.Message ?? "Payment required");
|
||||
}
|
||||
|
||||
ProtectionScopesCacheKey cacheKey = new(psRequest);
|
||||
|
||||
ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ProtectionScopesResponse psResponse;
|
||||
|
||||
if (cacheResponse != null)
|
||||
{
|
||||
return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
psResponse = cacheResponse;
|
||||
}
|
||||
|
||||
try
|
||||
else
|
||||
{
|
||||
this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
|
||||
}
|
||||
catch (PurviewJobException)
|
||||
{
|
||||
// QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
|
||||
psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply locally-cached protection scopes to the request and dispatch ProcessContent appropriately.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> ProcessWithCachedScopesAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesResponse psResponse,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier;
|
||||
|
||||
(bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
if (shouldProcess)
|
||||
{
|
||||
pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline;
|
||||
|
||||
if (executionMode == ExecutionMode.EvaluateOffline)
|
||||
{
|
||||
this._channelHandler.QueueJob(new ProcessContentJob(pcRequest));
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false);
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
return pcResponse;
|
||||
}
|
||||
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
@@ -256,30 +238,6 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call ProcessContent and invalidate the protection scopes cache when the response indicates the cached scopes are stale.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> CallProcessContentAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
List<DlpActionInfo>? dlpActions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcRequest.ScopeIdentifier != null && pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (dlpActions?.Count > 0)
|
||||
{
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dedupe policy actions received from the service.
|
||||
/// </summary>
|
||||
@@ -290,21 +248,9 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
if (actionInfos?.Count > 0)
|
||||
{
|
||||
List<DlpActionInfo> combinedActions = [];
|
||||
HashSet<(DlpAction Action, RestrictionAction? RestrictionAction)> seenActions = [];
|
||||
IEnumerable<DlpActionInfo> allActions = pcResponse.PolicyActions is null
|
||||
? actionInfos
|
||||
: pcResponse.PolicyActions.Concat(actionInfos);
|
||||
|
||||
foreach (DlpActionInfo actionInfo in allActions)
|
||||
{
|
||||
if (seenActions.Add((actionInfo.Action, actionInfo.RestrictionAction)))
|
||||
{
|
||||
combinedActions.Add(actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
pcResponse.PolicyActions = combinedActions;
|
||||
pcResponse.PolicyActions = pcResponse.PolicyActions is null ?
|
||||
actionInfos :
|
||||
[.. pcResponse.PolicyActions, .. actionInfos];
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
@@ -316,7 +262,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
/// <param name="pcRequest">The process content request.</param>
|
||||
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
|
||||
/// <returns>A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request.</returns>
|
||||
internal static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
private static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
{
|
||||
ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
|
||||
|
||||
@@ -338,11 +284,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
|
||||
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
|
||||
{
|
||||
if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locationMatch = true;
|
||||
break;
|
||||
}
|
||||
locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (activityMatch && locationMatch)
|
||||
|
||||
@@ -18,8 +18,6 @@ namespace Microsoft.Agents.AI.Purview.Serialization;
|
||||
[JsonSerializable(typeof(ContentActivitiesRequest))]
|
||||
[JsonSerializable(typeof(ContentActivitiesResponse))]
|
||||
[JsonSerializable(typeof(ProtectionScopesCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheEntry))]
|
||||
internal sealed partial class SourceGenerationContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -39,7 +39,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = [];
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
|
||||
@@ -66,15 +66,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
if (IsListToolsToolName(toolName))
|
||||
{
|
||||
ThrowIfListToolsArgumentsSpecified(arguments);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
|
||||
}
|
||||
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
|
||||
@@ -144,11 +145,10 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string trimmedUrl = serverUrl.Trim();
|
||||
var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, headers);
|
||||
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
|
||||
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
|
||||
|
||||
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -158,7 +158,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false);
|
||||
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
|
||||
this._clients[clientCacheKey] = newClient;
|
||||
return newClient;
|
||||
}
|
||||
@@ -168,19 +168,6 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-client cache key as a 4-tuple of
|
||||
/// (trimmed serverUrl, serverLabel, connectionName, headers hash). All four components
|
||||
/// participate so that callers using different labels/connections/headers receive
|
||||
/// distinct <see cref="McpClient"/> instances even when targeting the same URL.
|
||||
/// </summary>
|
||||
internal static (string Url, string Label, string Connection, string HeadersHash) BuildCacheKey(
|
||||
string trimmedUrl,
|
||||
string? serverLabel,
|
||||
string? connectionName,
|
||||
IDictionary<string, string>? headers) =>
|
||||
(trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers));
|
||||
|
||||
private async Task<McpClient> CreateClientAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
@@ -198,12 +185,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
|
||||
{
|
||||
// Disable cookies so handler-level state (cookie jar) cannot cross the cache-key
|
||||
// isolation boundary established by GetOrCreateClientAsync. The actual MCP auth
|
||||
// travels via AdditionalHeaders (set per-transport below), not session cookies.
|
||||
// CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler.
|
||||
HttpClientHandler handler = new() { UseCookies = false, CheckCertificateRevocationList = true };
|
||||
httpClient = new HttpClient(handler);
|
||||
httpClient = new HttpClient();
|
||||
this._ownedHttpClients[httpClientCacheKey] = httpClient;
|
||||
}
|
||||
|
||||
@@ -220,50 +202,26 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic, order-independent hash of the header set.
|
||||
/// Header names are lower-cased for case-insensitive matching (RFC 7230 §3.2).
|
||||
/// Header values remain case-sensitive (RFC 7235 — credentials are case-sensitive).
|
||||
/// </summary>
|
||||
#pragma warning disable CA1308 // RFC 7230 §3.2 requires lower-cased header names for case-insensitive comparison; CA1308's uppercase preference does not apply here
|
||||
internal static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
{
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Sort by lower-cased key for deterministic ordering, preserving value case.
|
||||
SortedDictionary<string, string> sorted = new(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, string> header in headers)
|
||||
{
|
||||
sorted[header.Key.ToLowerInvariant()] = header.Value;
|
||||
}
|
||||
|
||||
StringBuilder payload = new();
|
||||
// Build a deterministic, sorted representation of the headers
|
||||
// Within a single process lifetime, the hashcodes are consistent.
|
||||
// This will ensure that the same set of headers always produces the same hash, regardless of order.
|
||||
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
|
||||
int hashCode = 17;
|
||||
foreach (KeyValuePair<string, string> kvp in sorted)
|
||||
{
|
||||
payload.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
|
||||
}
|
||||
|
||||
byte[] inputBytes = Encoding.UTF8.GetBytes(payload.ToString());
|
||||
#if NET5_0_OR_GREATER
|
||||
byte[] hashBytes = SHA256.HashData(inputBytes);
|
||||
#else
|
||||
using SHA256 sha256 = SHA256.Create();
|
||||
byte[] hashBytes = sha256.ComputeHash(inputBytes);
|
||||
#endif
|
||||
|
||||
// Convert to hex string (compatible with net472/netstandard2.0)
|
||||
StringBuilder hex = new(hashBytes.Length * 2);
|
||||
foreach (byte b in hashBytes)
|
||||
{
|
||||
hex.Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return hex.ToString();
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
#pragma warning restore CA1308
|
||||
|
||||
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
|
||||
+1
-10
@@ -49,7 +49,7 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(this.Model.Items);
|
||||
if (expressionResult.Value is TableDataValue tableValue)
|
||||
{
|
||||
this._values = [.. tableValue.Values.Select(ToLoopValue)];
|
||||
this._values = [.. tableValue.Values.Select(value => value.ToFormula())];
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -99,15 +99,6 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
}
|
||||
}
|
||||
|
||||
// Power Fx wraps scalar array literals (`=[1, 2, 3]`) as `Table({Value: 1}, ...)`. Unwrap that single-column
|
||||
// `Value`-record shape so `Local.LoopValue` is the scalar; multi-field and other shapes pass through unchanged.
|
||||
private static FormulaValue ToLoopValue(DataValue value) =>
|
||||
value is RecordDataValue record
|
||||
&& record.Properties.Count == 1
|
||||
&& record.Properties.TryGetValue("Value", out DataValue? singleColumn)
|
||||
? singleColumn.ToFormula()
|
||||
: value.ToFormula();
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
|
||||
|
||||
+3
-65
@@ -13,7 +13,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
@@ -28,13 +27,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
|
||||
{
|
||||
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of evaluated parameters at approval-request time.
|
||||
/// </summary>
|
||||
private ApprovalSnapshot? _approvalSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Step identifiers for the function tool invocation workflow.
|
||||
/// </summary>
|
||||
@@ -77,10 +69,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
// If approval is required, add user input request content
|
||||
if (requireApproval)
|
||||
{
|
||||
// Snapshot the evaluated parameters.
|
||||
// If state mutates during the approval window, the approved values are used on resume.
|
||||
this._approvalSnapshot = new ApprovalSnapshot(functionName, arguments);
|
||||
|
||||
requestMessage.Contents.Add(new ToolApprovalRequestContent(this.Id, functionCall));
|
||||
}
|
||||
|
||||
@@ -167,31 +155,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
// Completes the action after processing the function result.
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clear the approval snapshot after the action completes so a subsequent
|
||||
// execution of the same executor instance doesn't reuse stale data.
|
||||
this._approvalSnapshot = null;
|
||||
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the approval snapshot from workflow state after a checkpoint restore.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -299,24 +262,7 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string functionName;
|
||||
Dictionary<string, object?>? arguments;
|
||||
|
||||
if (this._approvalSnapshot is { } snapshot)
|
||||
{
|
||||
// Use the snapshot captured at approval-request time so we invoke exactly what
|
||||
// the user approved, even if Power Fx state has mutated during the approval window.
|
||||
functionName = snapshot.FunctionName;
|
||||
arguments = snapshot.Arguments;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback for checkpoints created before approval snapshots were introduced.
|
||||
this.Logger.LogWarning("Approval snapshot missing for '{ActionId}'; falling back to expression re-evaluation.", this.Id);
|
||||
functionName = this.GetFunctionName();
|
||||
arguments = this.GetArguments();
|
||||
}
|
||||
|
||||
string functionName = this.GetFunctionName();
|
||||
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
|
||||
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
|
||||
|
||||
@@ -329,7 +275,8 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
};
|
||||
}
|
||||
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments.NormalizePortableValues());
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
|
||||
|
||||
object? result;
|
||||
try
|
||||
@@ -394,13 +341,4 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the evaluated parameters at approval-request time so that
|
||||
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
|
||||
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalSnapshot(
|
||||
string FunctionName,
|
||||
Dictionary<string, object?>? Arguments);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,4 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
internal static class MagenticConstants
|
||||
{
|
||||
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
|
||||
public const string CurrentSpeakerStateKey = nameof(CurrentSpeakerStateKey);
|
||||
}
|
||||
|
||||
+7
-56
@@ -90,7 +90,6 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
private MagenticTaskContext? _taskContext;
|
||||
private PortBinding? _planReviewPort;
|
||||
private string? _currentSpeakerExecutorId;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
@@ -197,46 +196,15 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
else
|
||||
{
|
||||
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan).
|
||||
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
|
||||
if (messages is { Count: > 0 })
|
||||
{
|
||||
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
|
||||
this._taskContext.ChatHistory.AddRange(messages);
|
||||
|
||||
// Share the reply with the other participants except the replier
|
||||
await this.BroadcastReplyToOtherParticipantsAsync(messages, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a participant's reply to every other participant so they share the running conversation.
|
||||
/// The messages are buffered (no <see cref="TurnToken"/> is sent) - they only become context for the participant's next turn.
|
||||
/// </summary>
|
||||
private ValueTask BroadcastReplyToOtherParticipantsAsync(
|
||||
List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// Without a known current speaker we cannot exclude the reply's author, so skip the broadcast
|
||||
// rather than risk echoing the reply back to its own author. This covers the window after a
|
||||
// checkpoint restore but before any delegation has set the current speaker.
|
||||
if (string.IsNullOrEmpty(this._currentSpeakerExecutorId))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
List<Task>? sendTasks = null;
|
||||
foreach (AIAgent agent in team)
|
||||
{
|
||||
string executorId = AIAgentHostExecutor.IdFor(agent);
|
||||
if (string.Equals(executorId, this._currentSpeakerExecutorId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
(sendTasks ??= []).Add(context.SendMessageAsync(messages, executorId, cancellationToken).AsTask());
|
||||
}
|
||||
return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks));
|
||||
}
|
||||
|
||||
private ChatMessage? _fullTaskLedgerMessage;
|
||||
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -319,18 +287,15 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
return;
|
||||
}
|
||||
|
||||
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
|
||||
{
|
||||
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
|
||||
taskContext.ChatHistory.Add(instruction);
|
||||
|
||||
// Target the instruction at the chosen speaker only.
|
||||
await context.SendMessageAsync(instruction, nextExecutorId, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._currentSpeakerExecutorId = nextExecutorId;
|
||||
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
|
||||
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -338,7 +303,6 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
{
|
||||
bool wasStalled = taskContext.IsStalled;
|
||||
taskContext.Reset();
|
||||
this._currentSpeakerExecutorId = null;
|
||||
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
|
||||
@@ -349,9 +313,9 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
taskContext.IsTerminated = true;
|
||||
this._currentSpeakerExecutorId = null;
|
||||
}
|
||||
|
||||
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Task contextStateTask = this._taskContext == null
|
||||
@@ -361,21 +325,14 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
cancellationToken: cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
Task currentSpeakerTask = context.QueueStateUpdateAsync(MagenticConstants.CurrentSpeakerStateKey,
|
||||
this._currentSpeakerExecutorId,
|
||||
cancellationToken: cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
|
||||
contextStateTask,
|
||||
currentSpeakerTask).ConfigureAwait(false);
|
||||
contextStateTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(),
|
||||
LoadContextStateAsync(),
|
||||
LoadCurrentSpeakerAsync()).ConfigureAwait(false);
|
||||
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
async Task LoadContextStateAsync()
|
||||
{
|
||||
@@ -387,11 +344,5 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, []);
|
||||
}
|
||||
}
|
||||
|
||||
async Task LoadCurrentSpeakerAsync()
|
||||
{
|
||||
this._currentSpeakerExecutorId = await context.ReadStateAsync<string?>(MagenticConstants.CurrentSpeakerStateKey, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ namespace Microsoft.Agents.AI;
|
||||
/// </remarks>
|
||||
public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
private const string AGUIProviderName = "ag-ui";
|
||||
|
||||
private readonly ChatClientAgentOptions? _agentOptions;
|
||||
private readonly HashSet<string> _aiContextProviderStateKeys;
|
||||
private readonly AIAgentMetadata _agentMetadata;
|
||||
@@ -564,7 +562,6 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
requestChatOptions.ModelId ??= this._agentOptions.ChatOptions.ModelId;
|
||||
requestChatOptions.PresencePenalty ??= this._agentOptions.ChatOptions.PresencePenalty;
|
||||
requestChatOptions.ResponseFormat ??= this._agentOptions.ChatOptions.ResponseFormat;
|
||||
requestChatOptions.Reasoning ??= this._agentOptions.ChatOptions.Reasoning;
|
||||
requestChatOptions.Seed ??= this._agentOptions.ChatOptions.Seed;
|
||||
requestChatOptions.Temperature ??= this._agentOptions.ChatOptions.Temperature;
|
||||
requestChatOptions.TopP ??= this._agentOptions.ChatOptions.TopP;
|
||||
@@ -818,7 +815,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(responseConversationId))
|
||||
{
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) && this._agentOptions?.ChatHistoryProvider is not null)
|
||||
if (this._agentOptions?.ChatHistoryProvider is not null)
|
||||
{
|
||||
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
|
||||
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
|
||||
@@ -932,9 +929,6 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAGUIProviderName(string? providerName) =>
|
||||
string.Equals(providerName, AGUIProviderName, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that <see cref="AIAgent.CurrentRunContext"/> contains the resolved session.
|
||||
/// </summary>
|
||||
@@ -982,17 +976,12 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
|
||||
{
|
||||
ChatHistoryProvider? provider =
|
||||
chatOptions?.ConversationId is null || IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
? this.ChatHistoryProvider
|
||||
: null;
|
||||
ChatHistoryProvider? provider = chatOptions?.ConversationId is null ? this.ChatHistoryProvider : null;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
{
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) &&
|
||||
this._agentOptions?.ThrowOnChatHistoryProviderConflict is true &&
|
||||
string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
|
||||
@@ -54,6 +54,7 @@ builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.MapGet("/readiness", () => Results.Ok());
|
||||
app.Run();
|
||||
|
||||
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
|
||||
|
||||
@@ -243,46 +243,6 @@ public sealed class AGUIAgentTests
|
||||
Assert.Contains(updates, u => u.Text == "Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithSession_SendsFullHistoryAfterThreadIdIsSetAsync()
|
||||
{
|
||||
// Arrange
|
||||
var captureHandler = new StateCapturingTestDelegatingHandler();
|
||||
captureHandler.AddResponse(
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "First response" },
|
||||
new TextMessageEndEvent { MessageId = "msg1" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
]);
|
||||
captureHandler.AddResponse(
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
|
||||
new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg2", Delta = "Second response" },
|
||||
new TextMessageEndEvent { MessageId = "msg2" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
|
||||
]);
|
||||
using HttpClient httpClient = new(captureHandler);
|
||||
|
||||
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
|
||||
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session))
|
||||
{
|
||||
}
|
||||
|
||||
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Second")], session))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal([1, 3], captureHandler.CapturedMessageCounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeSession_WithValidState_ReturnsChatClientAgentSessionAsync()
|
||||
{
|
||||
@@ -1726,12 +1686,10 @@ internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
|
||||
internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responseFactories = new();
|
||||
private readonly List<int> _capturedMessageCounts = [];
|
||||
|
||||
public bool RequestWasMade { get; private set; }
|
||||
public JsonElement? CapturedState { get; private set; }
|
||||
public int CapturedMessageCount { get; private set; }
|
||||
public IReadOnlyList<int> CapturedMessageCounts => this._capturedMessageCounts;
|
||||
|
||||
public void AddResponse(BaseEvent[] events)
|
||||
{
|
||||
@@ -1756,7 +1714,6 @@ internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
|
||||
this.CapturedState = input.State;
|
||||
}
|
||||
this.CapturedMessageCount = input.Messages.Count();
|
||||
this._capturedMessageCounts.Add(this.CapturedMessageCount);
|
||||
}
|
||||
|
||||
if (this._responseFactories.Count == 0)
|
||||
|
||||
+4
-4
@@ -182,7 +182,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
}
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task WorkflowEventsSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -278,7 +278,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task WorkflowSharedStateSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -376,7 +376,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task SubWorkflowsSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
@@ -452,7 +452,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task WorkflowHITLSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection that serializes tests mutating the <c>FOUNDRY_PROJECT_ENDPOINT</c>
|
||||
/// process environment variable. Without this, parallel test execution causes flaky
|
||||
/// races between tests that set / unset the variable.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class FoundryProjectEndpointEnvFixture
|
||||
{
|
||||
public const string Name = "FoundryProjectEndpointEnv";
|
||||
}
|
||||
+7
-126
@@ -1,9 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -54,144 +51,28 @@ public class FoundryToolboxBearerTokenHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_UsesAiAzureComScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturedContexts = new List<TokenRequestContext>();
|
||||
var credential = new Mock<TokenCredential>();
|
||||
credential
|
||||
.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TokenRequestContext, CancellationToken>((ctx, _) => capturedContexts.Add(ctx))
|
||||
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue));
|
||||
var (handler, _) = CreateHandlerPair(credential);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §4 mandates the https://ai.azure.com audience.
|
||||
Assert.Single(capturedContexts);
|
||||
Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request.
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Equal("Toolboxes=V1Preview", values.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync()
|
||||
public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
var header = values.Single();
|
||||
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature1", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature2", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature1,feature2", values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
|
||||
public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync()
|
||||
{
|
||||
// Override already contains the mandatory flag — must not be duplicated in the merged value.
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
var header = values.Single();
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += "Toolboxes=V1Preview".Length;
|
||||
}
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_PropagatesTraceContextFromActivityAsync()
|
||||
{
|
||||
// Arrange: activate an Activity so Activity.Current is populated.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
activity.TraceStateString = "vendor=value";
|
||||
activity.AddBaggage("user", "alice");
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §6.3 requires traceparent/tracestate/baggage propagation.
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues));
|
||||
Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues));
|
||||
Assert.Equal("vendor=value", tsValues.Single());
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("baggage", out var bgValues));
|
||||
Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync()
|
||||
{
|
||||
// Caller pre-set traceparent on the message; must not be duplicated or replaced.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent);
|
||||
|
||||
// Act
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var values));
|
||||
var list = values.ToList();
|
||||
Assert.Single(list);
|
||||
Assert.Equal(PresetTraceparent, list[0]);
|
||||
Assert.False(request.Headers.Contains("Foundry-Features"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxHealthCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_PendingStatus_ReturnsConfiguredFailureAsync()
|
||||
{
|
||||
// Arrange: a fresh FoundryToolboxService whose StartAsync has never run reports
|
||||
// Pending. The health check must surface that as the registration's failure
|
||||
// status so the platform waits before sending traffic.
|
||||
var service = CreateServiceWithoutStarting();
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("startup has not completed", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: no FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT is normal local-dev.
|
||||
// The container must still pass readiness because the rest of the agent is functional.
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var service = CreateServiceWithoutStarting(toolbox: "any");
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithFailedNamesAsync()
|
||||
{
|
||||
// Arrange: pre-registered toolbox at an unreachable endpoint forces StartAsync to
|
||||
// record the failure. The health-check must reflect Unhealthy and expose the
|
||||
// failed toolbox names in the result data so operators can diagnose without log
|
||||
// diving.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.True(result.Data.ContainsKey("failedToolboxes"));
|
||||
var failed = Assert.IsAssignableFrom<IReadOnlyList<string>>(result.Data["failedToolboxes"]);
|
||||
Assert.Equal("broken-toolbox", Assert.Single(failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_HealthyStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: an endpoint set but no pre-registered toolboxes is the legitimate
|
||||
// lazy-only setup. StartAsync reports Healthy and the check must agree.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
private static FoundryToolboxService CreateServiceWithoutStarting(string? toolbox = null)
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
if (toolbox is not null)
|
||||
{
|
||||
options.ToolboxNames.Add(toolbox);
|
||||
}
|
||||
return new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(HealthStatus failureStatus) =>
|
||||
new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration(
|
||||
name: "foundry-toolbox",
|
||||
instance: Mock.Of<IHealthCheck>(),
|
||||
failureStatus: failureStatus,
|
||||
tags: null),
|
||||
};
|
||||
}
|
||||
+5
-155
@@ -9,7 +9,6 @@ using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -40,17 +39,15 @@ public class FoundryToolboxServiceTests
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
|
||||
{
|
||||
// Ensure neither env var is set (tests may run in any CI environment)
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
// Ensure env var is not set (tests may run in any CI environment)
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
@@ -62,157 +59,10 @@ public class FoundryToolboxServiceTests
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(service.Tools);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpointAsync()
|
||||
{
|
||||
// Arrange: point the service at an unreachable host and confirm StartAsync
|
||||
// attempts to open the pre-registered toolbox (verified via FailedToolboxNames
|
||||
// recording the attempted name and StartupStatus reflecting the failure).
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions { ApiVersion = "v1" };
|
||||
options.ToolboxNames.Add("my-toolbox");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act: StartAsync attempts to connect to the invalid endpoint and fails.
|
||||
// The failure path records FailedToolboxNames; the value confirms the resolver ran.
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert: open failed, status reflects that (resolver was reached), and
|
||||
// the failed name matches — i.e. we attempted the right toolbox.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("my-toolbox", service.FailedToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj/");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
options.ToolboxNames.Add("tb");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Arrange/Act: when trailing-slash normalization works the open still fails
|
||||
// (host is unreachable), but FailedToolboxNames records the attempted name —
|
||||
// proof that the resolver did not throw on the slash and the URL was built.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("tb", service.FailedToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_EndpointOverrideWinsOverEnvAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://from-env.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
// EndpointOverride should take precedence over the env var.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/from-override",
|
||||
};
|
||||
options.ToolboxNames.Add("tb");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Override URL is unreachable; we expect Unhealthy (proving Start did try to open
|
||||
// a toolbox, i.e. did not fall into the NoEndpoint branch).
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStaysReachableAsync()
|
||||
{
|
||||
// Arrange: a syntactically valid but unreachable endpoint forces OpenToolboxAsync
|
||||
// to throw inside the catch-and-log path. The service must still complete StartAsync
|
||||
// (so the host doesn't crash) and surface the failure via StartupStatus.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("broken-toolbox", service.FailedToolboxNames[0]);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointAndNoToolboxes_ReportsHealthyAsync()
|
||||
{
|
||||
// No pre-registered toolboxes is a legitimate "lazy-only" setup. Health-check
|
||||
// should report Healthy so the readiness probe passes.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-46
@@ -43,7 +43,7 @@ public class HostedFoundryMemoryProviderScopesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_ComposesUserAndChatWithEscapedSeparator()
|
||||
public void PerUserAndChat_ComposesUserAndChatWithColon()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(TestUserId, TestChatId);
|
||||
@@ -54,51 +54,7 @@ public class HostedFoundryMemoryProviderScopesTests
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal($"{TestUserId}::{TestChatId}", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_EscapesColonsInUserAndChat()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession("alice:finance", "q2:final");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert - colons inside each part are escaped as \: , parts joined with ::
|
||||
Assert.Equal(@"alice\:finance::q2\:final", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_EscapesBackslashesInUserAndChat()
|
||||
{
|
||||
// Arrange
|
||||
var session = CreateTaggedSession(@"alice\corp", @"chat\1");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var state = initializer(session);
|
||||
|
||||
// Assert - backslashes escaped first as \\ , parts joined with ::
|
||||
Assert.Equal(@"alice\\corp::chat\\1", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerUserAndChat_DistinctContextsDoNotCollide()
|
||||
{
|
||||
// Arrange - two distinct (UserId, ChatId) pairs that collide under raw-colon composition.
|
||||
var sessionA = CreateTaggedSession("alice:finance", "q2");
|
||||
var sessionB = CreateTaggedSession("alice", "finance:q2");
|
||||
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
|
||||
|
||||
// Act
|
||||
var scopeA = initializer(sessionA).Scope.Scope;
|
||||
var scopeB = initializer(sessionB).Scope.Scope;
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(scopeA, scopeB);
|
||||
Assert.Equal($"{TestUserId}:{TestChatId}", state.Scope.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
-75
@@ -2,15 +2,9 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
@@ -141,73 +135,4 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
|
||||
// ── /readiness auto-mapping (Foundry container-image-spec §2) ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_MapsReadinessEndpoint_WhenTier3HostHasNotMappedItAsync()
|
||||
{
|
||||
// Arrange: Tier 3 host (WebApplication.CreateBuilder, no AgentHost) — Core SDK does
|
||||
// NOT map /readiness in this case, so MapFoundryResponses must cover the gap.
|
||||
using var host = await BuildTestHostAsync(static app => app.MapFoundryResponses());
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_DoesNotDuplicateReadiness_WhenAlreadyMappedAsync()
|
||||
{
|
||||
// Arrange: developer already mapped /readiness with a custom body. The auto-map
|
||||
// must detect the existing route and leave it untouched (no AmbiguousMatchException
|
||||
// at runtime, no override of the developer's response).
|
||||
const string CustomBody = "ready-from-developer";
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapGet("/readiness", () => Results.Text("ready-from-developer"));
|
||||
app.MapFoundryResponses();
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(CustomBody, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_CalledTwice_StillOnlyMapsReadinessOnceAsync()
|
||||
{
|
||||
// Arrange: defensive coverage for callers that map the responses pipeline twice
|
||||
// (e.g. once at the root and once under "openai/v1" in the existing AF samples).
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapFoundryResponses();
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
});
|
||||
|
||||
// Act + Assert: a single GET /readiness must succeed without ambiguous-match throw.
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<IHost> BuildTestHostAsync(Action<WebApplication> configure)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.SetupGet(a => a.Name).Returns("test-agent");
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
var app = builder.Build();
|
||||
configure(app);
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
+84
-201
@@ -4,92 +4,62 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public class GitHubCopilotAgentTests
|
||||
{
|
||||
private static void SkipIfCopilotNotConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("COPILOT_GITHUB_TOKEN")))
|
||||
{
|
||||
Assert.Skip("COPILOT_GITHUB_TOKEN not set; skipping GitHub Copilot integration tests.");
|
||||
}
|
||||
}
|
||||
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
|
||||
|
||||
private static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig: null);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.", session);
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("4", response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("4", response.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunStreamingAsync_WithSimplePrompt_ReturnsUpdatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig: null);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number."))
|
||||
{
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.", session))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
string fullText = string.Join("", updates.Select(u => u.Text));
|
||||
Assert.Contains("4", fullText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
string fullText = string.Join("", updates.Select(u => u.Text));
|
||||
Assert.Contains("4", fullText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithFunctionTool_InvokesToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
bool toolInvoked = false;
|
||||
|
||||
AIFunction weatherTool = AIFunctionFactory.Create((string location) =>
|
||||
@@ -101,42 +71,24 @@ public class GitHubCopilotAgentTests
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
Tools = [weatherTool],
|
||||
OnPermissionRequest = OnPermissionRequestAsync,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Mode = SystemMessageMode.Append,
|
||||
Content = "You are a weather assistant. Always use the GetWeather tool to answer weather questions.",
|
||||
},
|
||||
};
|
||||
await using GitHubCopilotAgent agent = new(
|
||||
client,
|
||||
tools: [weatherTool],
|
||||
instructions: "You are a helpful weather agent. Use the GetWeather tool to answer weather questions.");
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.True(toolInvoked);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.True(toolInvoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithSession_MaintainsContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -146,32 +98,23 @@ public class GitHubCopilotAgentTests
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act - First turn
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
|
||||
Assert.NotNull(response1);
|
||||
// Act - First turn
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
|
||||
Assert.NotNull(response1);
|
||||
|
||||
// Act - Second turn using same session
|
||||
AgentResponse response2 = await agent.RunAsync("What is my name?", session);
|
||||
// Act - Second turn using same session
|
||||
AgentResponse response2 = await agent.RunAsync("What is my name?", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response2);
|
||||
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response2);
|
||||
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithSessionResume_ContinuesConversationAsync()
|
||||
{
|
||||
// Arrange - First agent instance starts a conversation
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
string? sessionId = null;
|
||||
string? sessionId;
|
||||
|
||||
await using CopilotClient client1 = new(new CopilotClientOptions());
|
||||
await client1.StartAsync();
|
||||
@@ -181,44 +124,31 @@ public class GitHubCopilotAgentTests
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
|
||||
AgentSession session1 = await agent1.CreateSessionAsync();
|
||||
await agent1.RunAsync("Remember this number: 42.", session1);
|
||||
|
||||
try
|
||||
{
|
||||
await agent1.RunAsync("Remember this number: 42.", session1);
|
||||
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
|
||||
Assert.NotNull(sessionId);
|
||||
|
||||
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
|
||||
Assert.NotNull(sessionId);
|
||||
// Act - Second agent instance resumes the session
|
||||
await using CopilotClient client2 = new(new CopilotClientOptions());
|
||||
await client2.StartAsync();
|
||||
|
||||
// Act - Second agent instance resumes the session
|
||||
await using CopilotClient client2 = new(new CopilotClientOptions());
|
||||
await client2.StartAsync();
|
||||
await using GitHubCopilotAgent agent2 = new(
|
||||
client2,
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
|
||||
await using GitHubCopilotAgent agent2 = new(
|
||||
client2,
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
|
||||
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
|
||||
|
||||
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
|
||||
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("42", response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sessionId is not null)
|
||||
{
|
||||
await client1.DeleteSessionAsync(sessionId);
|
||||
}
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("42", response.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -228,30 +158,20 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'", session);
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithUrlPermissions_FetchesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -261,30 +181,20 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence", session);
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -303,31 +213,20 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("List the files in the current directory", session);
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("List the files in the current directory");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.NotEmpty(response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.NotEmpty(response.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "IntegrationDisabled")]
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -345,28 +244,12 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result", session);
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DeleteSessionAsync(CopilotClient client, AgentSession session)
|
||||
{
|
||||
if (session is GitHubCopilotAgentSession { SessionId: { } sessionId })
|
||||
{
|
||||
await client.DeleteSessionAsync(sessionId);
|
||||
}
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -3,7 +3,6 @@
|
||||
<PropertyGroup>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
@@ -16,7 +16,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithAllParameters_ReturnsGitHubCopilotAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
|
||||
const string TestId = "test-agent-id";
|
||||
const string TestName = "Test Agent";
|
||||
@@ -37,7 +37,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithMinimalParameters_ReturnsGitHubCopilotAgent()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
|
||||
// Act
|
||||
var agent = copilotClient.AsAIAgent(ownsClient: false, tools: null);
|
||||
@@ -61,7 +61,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithOwnsClient_ReturnsAgentThatOwnsClient()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
|
||||
// Act
|
||||
var agent = copilotClient.AsAIAgent(ownsClient: true, tools: null);
|
||||
@@ -75,7 +75,7 @@ public sealed class CopilotClientExtensionsTests
|
||||
public void AsAIAgent_WithTools_ReturnsAgentWithTools()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
|
||||
// Act
|
||||
|
||||
+21
-20
@@ -3,8 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Copilot;
|
||||
using GitHub.Copilot.Rpc;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
@@ -18,7 +17,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithCopilotClient_InitializesPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
const string TestId = "test-id";
|
||||
const string TestName = "test-name";
|
||||
const string TestDescription = "test-description";
|
||||
@@ -43,7 +42,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithDefaultParameters_UsesBaseProperties()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
|
||||
// Act
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
@@ -59,7 +58,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public async Task CreateSessionAsync_ReturnsGitHubCopilotAgentSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
// Act
|
||||
@@ -74,7 +73,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public async Task CreateSessionAsync_WithSessionId_ReturnsSessionWithSessionIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
const string TestSessionId = "test-session-id";
|
||||
|
||||
@@ -91,7 +90,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void Constructor_WithTools_InitializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
|
||||
// Act
|
||||
@@ -106,12 +105,12 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void CopySessionConfig_CopiesAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
var hooks = new SessionHooks();
|
||||
var infiniteSessions = new InfiniteSessionConfig();
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
@@ -123,7 +122,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
AvailableTools = ["tool1", "tool2"],
|
||||
ExcludedTools = ["tool3"],
|
||||
WorkingDirectory = "/workspace",
|
||||
ConfigDirectory = "/config",
|
||||
ConfigDir = "/config",
|
||||
Hooks = hooks,
|
||||
InfiniteSessions = infiniteSessions,
|
||||
OnPermissionRequest = permissionHandler,
|
||||
@@ -138,15 +137,17 @@ public sealed class GitHubCopilotAgentTests
|
||||
// Assert
|
||||
Assert.Equal("gpt-4o", result.Model);
|
||||
Assert.Equal("high", result.ReasoningEffort);
|
||||
Assert.Equal(systemMessage, result.SystemMessage);
|
||||
Assert.Same(tools, result.Tools);
|
||||
Assert.Same(systemMessage, result.SystemMessage);
|
||||
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
|
||||
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
|
||||
Assert.Equal("/workspace", result.WorkingDirectory);
|
||||
Assert.Equal("/config", result.ConfigDirectory);
|
||||
Assert.Equal("/config", result.ConfigDir);
|
||||
Assert.Same(hooks, result.Hooks);
|
||||
Assert.Same(infiniteSessions, result.InfiniteSessions);
|
||||
Assert.Same(permissionHandler, result.OnPermissionRequest);
|
||||
Assert.Same(userInputHandler, result.OnUserInputRequest);
|
||||
Assert.Same(mcpServers, result.McpServers);
|
||||
Assert.Equal(new List<string> { "skill1" }, result.DisabledSkills);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
@@ -155,12 +156,12 @@ public sealed class GitHubCopilotAgentTests
|
||||
public void CopyResumeSessionConfig_CopiesAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
|
||||
var hooks = new SessionHooks();
|
||||
var infiniteSessions = new InfiniteSessionConfig();
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
@@ -172,7 +173,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
AvailableTools = ["tool1", "tool2"],
|
||||
ExcludedTools = ["tool3"],
|
||||
WorkingDirectory = "/workspace",
|
||||
ConfigDirectory = "/config",
|
||||
ConfigDir = "/config",
|
||||
Hooks = hooks,
|
||||
InfiniteSessions = infiniteSessions,
|
||||
OnPermissionRequest = permissionHandler,
|
||||
@@ -192,7 +193,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
|
||||
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
|
||||
Assert.Equal("/workspace", result.WorkingDirectory);
|
||||
Assert.Equal("/config", result.ConfigDirectory);
|
||||
Assert.Equal("/config", result.ConfigDir);
|
||||
Assert.Same(hooks, result.Hooks);
|
||||
Assert.Same(infiniteSessions, result.InfiniteSessions);
|
||||
Assert.Same(permissionHandler, result.OnPermissionRequest);
|
||||
@@ -217,7 +218,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Assert.Null(result.OnUserInputRequest);
|
||||
Assert.Null(result.Hooks);
|
||||
Assert.Null(result.WorkingDirectory);
|
||||
Assert.Null(result.ConfigDirectory);
|
||||
Assert.Null(result.ConfigDir);
|
||||
Assert.True(result.Streaming);
|
||||
}
|
||||
|
||||
@@ -232,7 +233,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
Content = "Some streamed content that was already delivered via delta events"
|
||||
}
|
||||
};
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions());
|
||||
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
|
||||
const string TestId = "agent-id";
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage);
|
||||
|
||||
-1
@@ -3,7 +3,6 @@
|
||||
<PropertyGroup>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,7 +27,6 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Null(options.ChatHistoryProvider);
|
||||
Assert.Null(options.AIContextProviders);
|
||||
Assert.False(options.DisableToolApproval);
|
||||
Assert.False(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.False(options.DisableFileMemory);
|
||||
Assert.False(options.DisableFileAccess);
|
||||
Assert.False(options.DisableWebSearch);
|
||||
@@ -81,7 +80,6 @@ public class HarnessAgentOptionsTests
|
||||
AIContextProviders = contextProviders,
|
||||
MaximumIterationsPerRequest = 42,
|
||||
DisableToolApproval = true,
|
||||
DisableNonApprovalRequiredFunctionBypassing = true,
|
||||
DisableFileMemory = true,
|
||||
FileMemoryStore = fileMemoryStore,
|
||||
DisableFileAccess = true,
|
||||
@@ -114,7 +112,6 @@ public class HarnessAgentOptionsTests
|
||||
Assert.Same(contextProviders, options.AIContextProviders);
|
||||
Assert.Equal(42, options.MaximumIterationsPerRequest);
|
||||
Assert.True(options.DisableToolApproval);
|
||||
Assert.True(options.DisableNonApprovalRequiredFunctionBypassing);
|
||||
Assert.True(options.DisableFileMemory);
|
||||
Assert.Same(fileMemoryStore, options.FileMemoryStore);
|
||||
Assert.True(options.DisableFileAccess);
|
||||
|
||||
@@ -21,12 +21,9 @@ public class HarnessAgentTests
|
||||
|
||||
/// <summary>
|
||||
/// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors.
|
||||
/// Compaction is enabled by default for backward compatibility with existing tests.
|
||||
/// </summary>
|
||||
private static HarnessAgentOptions CreateAllDisabledOptions() => new()
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
MaxOutputTokens = TestMaxOutputTokens,
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
@@ -46,7 +43,7 @@ public class HarnessAgentTests
|
||||
public void Constructor_ThrowsWhenChatClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!, TestMaxContextWindowTokens, TestMaxOutputTokens));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,10 +54,9 @@ public class HarnessAgentTests
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions { MaxContextWindowTokens = 0, MaxOutputTokens = TestMaxOutputTokens };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 0, TestMaxOutputTokens));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,10 +67,9 @@ public class HarnessAgentTests
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions { MaxContextWindowTokens = 100_000, MaxOutputTokens = 100_000 };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 100_000, 100_000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -87,7 +82,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -110,7 +105,7 @@ public class HarnessAgentTests
|
||||
options.Description = "A test agent";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
@@ -129,7 +124,7 @@ public class HarnessAgentTests
|
||||
options.Id = "my-agent-id";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-agent-id", agent.Id);
|
||||
@@ -149,7 +144,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -169,7 +164,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Temperature = 0.5f };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -189,7 +184,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -210,7 +205,7 @@ public class HarnessAgentTests
|
||||
options.HarnessInstructions = "Custom harness rules.";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -231,7 +226,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -252,7 +247,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." };
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -272,7 +267,7 @@ public class HarnessAgentTests
|
||||
options.HarnessInstructions = string.Empty;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -294,7 +289,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -315,7 +310,7 @@ public class HarnessAgentTests
|
||||
options.ChatHistoryProvider = customProvider;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -337,7 +332,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -358,7 +353,7 @@ public class HarnessAgentTests
|
||||
var rawClient = mockClient.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(rawClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — the pipeline wraps the raw client, so the outer client is not the same object.
|
||||
@@ -383,7 +378,7 @@ public class HarnessAgentTests
|
||||
options.AIContextProviders = [customProvider];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — the custom provider should appear in the inner agent's AIContextProviders.
|
||||
@@ -403,7 +398,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -437,7 +432,7 @@ public class HarnessAgentTests
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ChatOptions = new ChatOptions { Tools = [tool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -464,10 +459,8 @@ public class HarnessAgentTests
|
||||
};
|
||||
|
||||
// Act
|
||||
_ = new HarnessAgent(chatClient, new HarnessAgentOptions
|
||||
_ = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
MaxOutputTokens = TestMaxOutputTokens,
|
||||
ChatOptions = sourceChatOptions,
|
||||
});
|
||||
|
||||
@@ -490,7 +483,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Same(agent, agent.GetService<HarnessAgent>());
|
||||
@@ -506,7 +499,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ChatClientAgent>());
|
||||
@@ -531,7 +524,7 @@ public class HarnessAgentTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -572,7 +565,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent();
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -593,7 +586,7 @@ public class HarnessAgentTests
|
||||
options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" };
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(options);
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -610,7 +603,7 @@ public class HarnessAgentTests
|
||||
public void AsHarnessAgent_ThrowsWhenChatClientIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent());
|
||||
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -629,7 +622,7 @@ public class HarnessAgentTests
|
||||
options.DisableToolApproval = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
|
||||
@@ -645,7 +638,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.GetService<ToolApprovalAgent>());
|
||||
@@ -685,7 +678,7 @@ public class HarnessAgentTests
|
||||
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
|
||||
};
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -698,97 +691,6 @@ public class HarnessAgentTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: NonApprovalRequiredFunctionBypassing
|
||||
|
||||
/// <summary>
|
||||
/// Verify that by default, when a response contains a mix of tools that require approval and tools that do not,
|
||||
/// only the approval-required tool is surfaced to the caller. The non-approval-required tool is bypassed
|
||||
/// (stored as auto-approved) by the <c>NonApprovalRequiredFunctionBypassingChatClient</c> decorator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NonApprovalRequiredFunctionBypassing_BypassesNonApprovalToolsByDefaultAsync()
|
||||
{
|
||||
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("call1", "NormalTool"),
|
||||
new FunctionCallContent("call2", "ApprovalTool"),
|
||||
])));
|
||||
|
||||
// Disable ToolApproval so the approval requests surface in the response instead of being handled.
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — only the approval-required tool surfaces as an approval request; the normal tool is bypassed.
|
||||
var approvalRequests = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
var approvalRequest = Assert.Single(approvalRequests);
|
||||
Assert.Equal("ApprovalTool", Assert.IsType<FunctionCallContent>(approvalRequest.ToolCall).Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when bypassing is disabled, all tools (including those that do not require approval) are surfaced
|
||||
/// as approval requests, reflecting the all-or-nothing behavior of <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NonApprovalRequiredFunctionBypassing_SurfacesAllApprovalsWhenDisabledAsync()
|
||||
{
|
||||
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
|
||||
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
|
||||
|
||||
var mockClient = new Mock<IChatClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("call1", "NormalTool"),
|
||||
new FunctionCallContent("call2", "ApprovalTool"),
|
||||
])));
|
||||
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableNonApprovalRequiredFunctionBypassing = true;
|
||||
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
// Assert — both tools surface as approval requests because bypassing is disabled.
|
||||
var approvalRequests = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Select(r => ((FunctionCallContent)r.ToolCall).Name)
|
||||
.ToList();
|
||||
Assert.Equal(2, approvalRequests.Count);
|
||||
Assert.Contains("NormalTool", approvalRequests);
|
||||
Assert.Contains("ApprovalTool", approvalRequests);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Feature: OpenTelemetry
|
||||
|
||||
/// <summary>
|
||||
@@ -803,7 +705,7 @@ public class HarnessAgentTests
|
||||
options.DisableOpenTelemetry = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
|
||||
@@ -819,7 +721,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
|
||||
// Assert
|
||||
Assert.Null(agent.GetService<OpenTelemetryAgent>());
|
||||
@@ -838,7 +740,7 @@ public class HarnessAgentTests
|
||||
options.OpenTelemetrySourceName = "MyApp.AgentTracing";
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
|
||||
@@ -865,7 +767,7 @@ public class HarnessAgentTests
|
||||
var options = CreateAllDisabledOptions();
|
||||
options.DisableWebSearch = false;
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -890,7 +792,7 @@ public class HarnessAgentTests
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -923,7 +825,7 @@ public class HarnessAgentTests
|
||||
options.DisableWebSearch = false;
|
||||
options.ChatOptions = new ChatOptions { Tools = [userTool] };
|
||||
|
||||
var agent = new HarnessAgent(mockClient.Object, options);
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
@@ -951,7 +853,7 @@ public class HarnessAgentTests
|
||||
options.DisableTodoProvider = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -969,7 +871,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -996,7 +898,7 @@ public class HarnessAgentTests
|
||||
options.DisableAgentModeProvider = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1014,7 +916,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1045,7 +947,7 @@ public class HarnessAgentTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — AgentModeProvider should be present (we can't easily inspect its internal options,
|
||||
@@ -1070,7 +972,7 @@ public class HarnessAgentTests
|
||||
options.DisableFileMemory = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1088,7 +990,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1113,7 +1015,7 @@ public class HarnessAgentTests
|
||||
options.FileMemoryStore = customStore;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — FileMemoryProvider should be present with the custom store.
|
||||
@@ -1137,7 +1039,7 @@ public class HarnessAgentTests
|
||||
options.DisableFileAccess = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1155,7 +1057,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1180,7 +1082,7 @@ public class HarnessAgentTests
|
||||
options.FileAccessStore = customStore;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — FileAccessProvider should be present with the custom store.
|
||||
@@ -1204,7 +1106,7 @@ public class HarnessAgentTests
|
||||
options.DisableAgentSkillsProvider = false;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1222,7 +1124,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1247,7 +1149,7 @@ public class HarnessAgentTests
|
||||
options.AgentSkillsSource = customSource;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — AgentSkillsProvider should be present.
|
||||
@@ -1271,7 +1173,7 @@ public class HarnessAgentTests
|
||||
options.MaximumIterationsPerRequest = 42;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
@@ -1290,7 +1192,7 @@ public class HarnessAgentTests
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
|
||||
|
||||
@@ -1318,7 +1220,7 @@ public class HarnessAgentTests
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(mockClient.Object);
|
||||
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — agent wrappers
|
||||
@@ -1361,7 +1263,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = [bgAgentMock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1381,7 +1283,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1404,7 +1306,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = Array.Empty<AIAgent>();
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1435,7 +1337,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgentsProviderOptions = providerOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
@@ -1472,7 +1374,7 @@ public class HarnessAgentTests
|
||||
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
|
||||
|
||||
@@ -1513,7 +1415,7 @@ public class HarnessAgentTests
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1533,7 +1435,7 @@ public class HarnessAgentTests
|
||||
options.ShellExecutor = null;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert
|
||||
@@ -1565,7 +1467,7 @@ public class HarnessAgentTests
|
||||
options.ShellExecutor = executorMock.Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClientMock.Object, options);
|
||||
var agent = new HarnessAgent(chatClientMock.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var session = await agent.CreateSessionAsync();
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
|
||||
|
||||
@@ -1594,7 +1496,7 @@ public class HarnessAgentTests
|
||||
options.ShellEnvironmentProviderOptions = envOptions;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
|
||||
// Assert — provider should exist (options wiring is validated by the provider's behavior)
|
||||
@@ -1618,7 +1520,7 @@ public class HarnessAgentTests
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1635,7 +1537,7 @@ public class HarnessAgentTests
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: services);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1653,7 +1555,7 @@ public class HarnessAgentTests
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1671,7 +1573,7 @@ public class HarnessAgentTests
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(CreateAllDisabledOptions(), loggerFactory, services);
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -1693,8 +1595,6 @@ public class HarnessAgentTests
|
||||
// Act — use options that leave CompactionProvider and AgentSkillsProvider enabled
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
MaxOutputTokens = TestMaxOutputTokens,
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
@@ -1703,7 +1603,7 @@ public class HarnessAgentTests
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
};
|
||||
var agent = new HarnessAgent(chatClient, options, mockLoggerFactory.Object);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options, mockLoggerFactory.Object);
|
||||
|
||||
// Assert — CreateLogger should have been called by one or more downstream components
|
||||
Assert.NotNull(agent);
|
||||
@@ -1725,7 +1625,7 @@ public class HarnessAgentTests
|
||||
.Returns(null!);
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: mockServices.Object);
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: mockServices.Object);
|
||||
|
||||
// Assert — the service provider should have been queried during pipeline construction
|
||||
Assert.NotNull(agent);
|
||||
@@ -1733,91 +1633,4 @@ public class HarnessAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Compaction Opt-in
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructing without token values succeeds (compaction disabled).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithoutTokenValues()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableAgentSkillsProvider = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert — compaction should be disabled (no chat reducer)
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
Assert.NotNull(innerAgent);
|
||||
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(historyProvider);
|
||||
Assert.Null(historyProvider!.ChatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when only MaxContextWindowTokens is provided (no MaxOutputTokens), compaction is disabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithOnlyMaxContextWindowTokens()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = TestMaxContextWindowTokens,
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableAgentSkillsProvider = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, options);
|
||||
|
||||
// Assert — compaction should be disabled (only one token value provided)
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
Assert.NotNull(innerAgent);
|
||||
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(historyProvider);
|
||||
Assert.Null(historyProvider!.ChatReducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when both token values are provided, the agent is constructed successfully with compaction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithBothTokenValues()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
|
||||
|
||||
// Assert — compaction should be enabled (chat reducer configured)
|
||||
var innerAgent = agent.GetService<ChatClientAgent>();
|
||||
Assert.NotNull(innerAgent);
|
||||
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
Assert.NotNull(historyProvider);
|
||||
Assert.NotNull(historyProvider!.ChatReducer);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+7
-7
@@ -60,7 +60,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task SingleAgentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
|
||||
@@ -148,7 +148,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
|
||||
@@ -198,7 +198,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
|
||||
@@ -216,7 +216,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
|
||||
@@ -272,7 +272,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task LongRunningToolsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
|
||||
@@ -362,7 +362,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task AgentAsMcpToolAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool");
|
||||
@@ -402,7 +402,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[RetryFact(2, 5000)]
|
||||
public async Task ReliableStreamingSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
|
||||
|
||||
+5
-5
@@ -62,7 +62,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
return default;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[Fact]
|
||||
public async Task SequentialWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
|
||||
@@ -168,7 +168,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[Fact]
|
||||
public async Task HITLWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL");
|
||||
@@ -277,7 +277,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[Fact]
|
||||
public async Task WorkflowMcpToolSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
|
||||
@@ -333,7 +333,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[Fact]
|
||||
public async Task WorkflowAndAgentsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
|
||||
@@ -385,7 +385,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
|
||||
[Fact]
|
||||
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
|
||||
|
||||
+6
-89
@@ -16,7 +16,6 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
private const string TestAuthenticationType = "TestAuth";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
@@ -102,25 +101,6 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
|
||||
/// non-unique display name claim. This guards against the session-isolation collision described in
|
||||
/// the security report where two principals sharing the same name claim received the same key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
|
||||
{
|
||||
// Arrange - only a display-name claim is present; the default provider must not use it.
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
@@ -128,7 +108,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -211,10 +191,10 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, FirstValue),
|
||||
new Claim(ClaimTypes.NameIdentifier, SecondValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
@@ -239,7 +219,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
@@ -249,66 +229,6 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for the session-isolation collision security report: two distinct authenticated
|
||||
/// principals that share the same display-name claim but have different stable identifiers and tenants
|
||||
/// must produce distinct isolation keys under the default options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
|
||||
{
|
||||
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
|
||||
const string CommonName = "John Doe";
|
||||
|
||||
var principalA = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-a"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-a"));
|
||||
|
||||
var principalB = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
|
||||
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
|
||||
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
|
||||
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("oid-user-a", principalAKey);
|
||||
Assert.Equal("oid-user-b", principalBKey);
|
||||
Assert.NotEqual(principalAKey, principalBKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
|
||||
/// even if a claim of the configured type is present. The provider must not derive an isolation key
|
||||
/// from claims on an unauthenticated identity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
|
||||
{
|
||||
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
|
||||
var unauthenticatedIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.False(unauthenticatedIdentity.IsAuthenticated);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
@@ -316,7 +236,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
@@ -327,8 +247,5 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, TestAuthenticationType));
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -115,24 +115,6 @@ public sealed class PurviewClientTests : IDisposable
|
||||
Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithProcessInline_IncludesPreferHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
request.ProcessInline = true;
|
||||
var expectedResponse = new ProcessContentResponse { Id = "test-id" };
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("evaluateInline", this._handler.PreferHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
@@ -548,7 +530,6 @@ public sealed class PurviewClientTests : IDisposable
|
||||
public HttpMethod? RequestMethod { get; private set; }
|
||||
public string? AuthorizationHeader { get; private set; }
|
||||
public string? IfNoneMatchHeader { get; private set; }
|
||||
public string? PreferHeader { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -566,11 +547,6 @@ public sealed class PurviewClientTests : IDisposable
|
||||
this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValues("Prefer", out var preferValues))
|
||||
{
|
||||
this.PreferHeader = string.Join(", ", preferValues);
|
||||
}
|
||||
|
||||
// Throw HttpRequestException if configured
|
||||
if (this.ShouldThrowHttpRequestException)
|
||||
{
|
||||
|
||||
+40
-358
@@ -3,14 +3,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
@@ -52,6 +50,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -68,8 +70,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -107,6 +109,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -123,8 +129,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -162,6 +168,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -178,8 +188,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -203,99 +213,6 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_DeduplicatesCombinedPolicyActionsByActionAndRestrictionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Test message")
|
||||
];
|
||||
PurviewSettings settings = CreateValidPurviewSettings();
|
||||
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
DlpActionInfo processContentAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo duplicateScopeAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo restrictionOnlyAction = new() { RestrictionAction = RestrictionAction.Block };
|
||||
ProcessContentResponse pcResponse = new()
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
processContentAction
|
||||
]
|
||||
};
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline,
|
||||
PolicyActions =
|
||||
[
|
||||
duplicateScopeAction,
|
||||
restrictionOnlyAction
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pcResponse.PolicyActions);
|
||||
Assert.Equal(2, pcResponse.PolicyActions.Count);
|
||||
Assert.Same(processContentAction, pcResponse.PolicyActions[0]);
|
||||
Assert.Same(restrictionOnlyAction, pcResponse.PolicyActions[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckApplicableScopes_MatchesAnyLocationInScope()
|
||||
{
|
||||
// Arrange
|
||||
ProcessContentRequest pcRequest = CreateProcessContentRequest();
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123"),
|
||||
new("microsoft.graph.policyLocationApplication", "different-app")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
(bool shouldProcess, _, ExecutionMode executionMode) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
// Assert
|
||||
Assert.True(shouldProcess);
|
||||
Assert.Equal(ExecutionMode.EvaluateInline, executionMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
|
||||
{
|
||||
@@ -362,9 +279,12 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
ScopeIdentifier = "etag-1",
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
@@ -379,8 +299,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -416,6 +336,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -431,8 +355,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -508,9 +432,13 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -539,9 +467,13 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -552,260 +484,10 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal(userId, result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job.
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_ReturnsShouldBlockTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
new() { Action = DlpAction.BlockAccess }
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.shouldBlock);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockChannelHandler.Setup(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()))
|
||||
.Throws(new PurviewJobException("queue unavailable"));
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued.
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithCachedPaymentRequiredState_ThrowsPaymentRequiredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
It.IsAny<PaymentRequiredCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentRequiredCacheEntry("Payment required"));
|
||||
|
||||
// Act + Assert
|
||||
await Assert.ThrowsAsync<PurviewPaymentRequiredException>(() =>
|
||||
this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
|
||||
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = new("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
ProtectionScopesCacheKey cacheKey = new(request);
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest()));
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
cacheProvider.Verify(x => x.SetAsync(
|
||||
It.Is<PaymentRequiredCacheKey>(key => key.TenantId == "tenant-123"),
|
||||
It.Is<PaymentRequiredCacheEntry>(entry => entry.Message == "Payment required"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = CreateProtectionScopesRequest();
|
||||
ScopeRetrievalJob job = new(request, new ProtectionScopesCacheKey(request), CreateProcessContentRequest());
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(job);
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
channelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ProtectionScopesRequest CreateProtectionScopesRequest()
|
||||
{
|
||||
return new ProtectionScopesRequest("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessContentRequest CreateProcessContentRequest()
|
||||
{
|
||||
PurviewTextContent content = new("Test content");
|
||||
ProcessConversationMetadata metadata = new(content, "msg-123", false, "Test message", "test-correlation-id");
|
||||
ActivityMetadata activityMetadata = new(Activity.UploadText);
|
||||
DeviceMetadata deviceMetadata = new()
|
||||
{
|
||||
OperatingSystemSpecifications = new()
|
||||
{
|
||||
OperatingSystemPlatform = "Windows",
|
||||
OperatingSystemVersion = "10"
|
||||
}
|
||||
};
|
||||
IntegratedAppMetadata integratedAppMetadata = new()
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
PolicyLocation policyLocation = new("microsoft.graph.policyLocationApplication", "app-123");
|
||||
ProtectedAppMetadata protectedAppMetadata = new(policyLocation)
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
ContentToProcess contentToProcess = new(
|
||||
[metadata],
|
||||
activityMetadata,
|
||||
deviceMetadata,
|
||||
integratedAppMetadata,
|
||||
protectedAppMetadata);
|
||||
|
||||
return new ProcessContentRequest(contentToProcess, "user-123", "tenant-123");
|
||||
}
|
||||
|
||||
private static PurviewSettings CreateValidPurviewSettings()
|
||||
{
|
||||
return new PurviewSettings("TestApp")
|
||||
|
||||
-109
@@ -347,115 +347,6 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
Assert.Equal(expectedSetting, capturedChatOptions.RawRepresentationFactory(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request takes priority over the agent's.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestReasoningOverAgentReasoningAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
|
||||
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> falls back to the agent's when the request has none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingFallsBackToAgentReasoningWhenRequestHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions()));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(agentReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(agentReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request is used when the agent has none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingUsesRequestReasoningWhenAgentHasNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new ChatOptions()
|
||||
});
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.NotNull(capturedChatOptions.Reasoning);
|
||||
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
|
||||
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging handles all scalar properties correctly.
|
||||
/// </summary>
|
||||
|
||||
-183
@@ -321,189 +321,6 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region ComputeHeadersHash Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString()
|
||||
{
|
||||
// Act
|
||||
string result = DefaultMcpToolHandler.ComputeHeadersHash(null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString()
|
||||
{
|
||||
// Act
|
||||
string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary<string, string>());
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> headers1 = new()
|
||||
{
|
||||
["Authorization"] = "Bearer token123",
|
||||
["X-Custom"] = "value1"
|
||||
};
|
||||
Dictionary<string, string> headers2 = new()
|
||||
{
|
||||
["X-Custom"] = "value1",
|
||||
["Authorization"] = "Bearer token123"
|
||||
};
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash()
|
||||
{
|
||||
// Arrange — RFC 7230: header names are case-insensitive
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token" };
|
||||
Dictionary<string, string> headers2 = new() { ["authorization"] = "Bearer token" };
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash()
|
||||
{
|
||||
// Arrange — RFC 7235: credentials are case-sensitive
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
|
||||
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token1" };
|
||||
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer token2" };
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache Key Discrimination Tests
|
||||
|
||||
// These tests exercise BuildCacheKey directly because the integration path
|
||||
// (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior
|
||||
// without standing up a real MCP server — McpClient.CreateAsync fails before
|
||||
// _clients[key] = newClient runs, so nothing ever gets cached.
|
||||
// Tuple equality on the returned 4-tuple verifies that the dimensions
|
||||
// collectively discriminate cache entries.
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_SameInputs_ReturnsEqualKeys()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> headers = new() { ["Authorization"] = "Bearer token" };
|
||||
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys()
|
||||
{
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
key1.Connection.Should().Be("connection-a");
|
||||
key2.Connection.Should().Be("connection-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys()
|
||||
{
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
key1.Label.Should().Be("label-a");
|
||||
key2.Label.Should().Be("label-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys()
|
||||
{
|
||||
// Arrange — RFC 3986: URL path is case-sensitive
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys()
|
||||
{
|
||||
// Arrange — RFC 7235: credentials are case-sensitive
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
|
||||
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
|
||||
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2);
|
||||
|
||||
// Assert — header value case must propagate into the cache key
|
||||
key1.Should().NotBe(key2);
|
||||
key1.HeadersHash.Should().NotBe(key2.HeadersHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString()
|
||||
{
|
||||
// Act
|
||||
var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null);
|
||||
|
||||
// Assert — verifies null-safety contract callers rely on
|
||||
key.Label.Should().BeEmpty();
|
||||
key.Connection.Should().BeEmpty();
|
||||
key.HeadersHash.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reserved Tools/List Tests
|
||||
|
||||
[Fact]
|
||||
|
||||
-61
@@ -170,67 +170,6 @@ public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActi
|
||||
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
|
||||
/// the loop value must expose the bare scalar, not the single-column wrapper record.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
FormulaValue currentValue = this.State.Get(CurrentValueName);
|
||||
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
|
||||
Assert.Equal(1m, currentValue.ToObject());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
|
||||
/// they are preserved as records so the field name remains accessible inside the loop body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CurrentValueName = "CurrentValue";
|
||||
this.SetVariableState(CurrentValueName);
|
||||
|
||||
TableDataValue tableValue = DataValue.TableFromRecords(
|
||||
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
|
||||
|
||||
Foreach model = this.CreateModel(
|
||||
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
|
||||
items: ValueExpression.Literal(tableValue),
|
||||
valueName: CurrentValueName,
|
||||
indexName: null);
|
||||
ForeachExecutor action = new(model, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
|
||||
|
||||
// Assert
|
||||
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
|
||||
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeachTakeLastAsync()
|
||||
{
|
||||
|
||||
-355
@@ -1,21 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeFunctionToolExecutor.ApprovalSnapshot;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
@@ -271,323 +261,6 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approval Snapshot Security Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating the function-name variable after approval does not change
|
||||
/// which function is actually invoked. The originally-approved name must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedFunctionName = "safe_readonly_query";
|
||||
const string MutatedFunctionName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync),
|
||||
variableName: "TargetFunction");
|
||||
|
||||
string? capturedFunctionName = null;
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[
|
||||
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
|
||||
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
|
||||
],
|
||||
onInvoke: name => capturedFunctionName = name);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve (they saw "safe_readonly_query" in the approval UI)
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved function must be invoked, not the mutated one
|
||||
Assert.NotNull(capturedFunctionName);
|
||||
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating an argument variable after approval does not change
|
||||
/// the arguments actually passed to the invoked function.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FunctionName = "process_query";
|
||||
const string ArgumentKey = "query";
|
||||
const string ApprovedQuery = "SELECT * FROM users LIMIT 10";
|
||||
const string MutatedQuery = "DROP TABLE users CASCADE; --";
|
||||
|
||||
this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModelWithVariableArgument(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync),
|
||||
functionName: FunctionName,
|
||||
argumentKey: ArgumentKey,
|
||||
variableName: "SqlQuery");
|
||||
|
||||
AIFunctionArguments? capturedArguments = null;
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[AIFunctionFactory.Create((string query) => $"executed:{query}", name: FunctionName)],
|
||||
onInvokeArguments: args => capturedArguments = args);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved argument must be used, not the mutated one
|
||||
Assert.NotNull(capturedArguments);
|
||||
Assert.Equal(ApprovedQuery, capturedArguments[ArgumentKey]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the approval snapshot survives a checkpoint/restore cycle.
|
||||
/// After restore, the originally-approved function must still be used even if state was mutated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedFunctionName = "safe_readonly_query";
|
||||
const string MutatedFunctionName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync),
|
||||
variableName: "TargetFunction");
|
||||
|
||||
string? capturedFunctionName = null;
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[
|
||||
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
|
||||
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
|
||||
],
|
||||
onInvoke: name => capturedFunctionName = name);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate checkpoint: persist to state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate restore on a "new" executor instance by clearing the in-memory field via reflection
|
||||
// (In production, a new executor instance would be created with _approvalSnapshot == null)
|
||||
typeof(InvokeFunctionToolExecutor)
|
||||
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(action, null);
|
||||
|
||||
// Restore from state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Mutate state after restore (simulating parallel branch)
|
||||
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved function must be invoked, not the mutated one
|
||||
Assert.NotNull(capturedFunctionName);
|
||||
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the approval snapshot is cleared after a completed approval cycle,
|
||||
/// both in-memory and in the persisted state store. This prevents stale data from
|
||||
/// influencing a subsequent execution of the same executor instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FunctionName = "any_function";
|
||||
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync),
|
||||
functionName: FunctionName,
|
||||
requireApproval: true);
|
||||
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[AIFunctionFactory.Create(() => "result", name: FunctionName)]);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - run the full approval cycle
|
||||
Dictionary<string, object?> stateStore = [];
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore(stateStore);
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Sanity: snapshot was captured
|
||||
FieldInfo snapshotField = typeof(InvokeFunctionToolExecutor)
|
||||
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
Assert.NotNull(snapshotField.GetValue(action));
|
||||
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - both in-memory field and persisted state are cleared
|
||||
Assert.Null(snapshotField.GetValue(action));
|
||||
Assert.True(stateStore.ContainsKey("_approvalSnapshot"));
|
||||
Assert.Null(stateStore["_approvalSnapshot"]);
|
||||
}
|
||||
|
||||
private static ExternalInputResponse CreateApprovalResponse(string actionId, bool approved)
|
||||
{
|
||||
FunctionCallContent functionCall = new(callId: actionId, name: "ignored");
|
||||
ToolApprovalRequestContent approvalRequest = new(actionId, functionCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved);
|
||||
return new ExternalInputResponse(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
}
|
||||
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContext()
|
||||
{
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests).
|
||||
/// Optionally accepts an externally-owned dictionary so callers can inspect the persisted state.
|
||||
/// </summary>
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContextWithStateStore(Dictionary<string, object?>? stateStore = null)
|
||||
{
|
||||
stateStore ??= [];
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<ApprovalSnapshot?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, ApprovalSnapshot?, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value)
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.ReadStateAsync<ApprovalSnapshot>(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string?, CancellationToken>((key, _, _) =>
|
||||
new ValueTask<ApprovalSnapshot?>(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null));
|
||||
mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<string>());
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a protected method on the executor via reflection (for testing checkpoint hooks).
|
||||
/// </summary>
|
||||
private static async ValueTask InvokeProtectedMethodAsync(InvokeFunctionToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
MethodInfo method = typeof(InvokeFunctionToolExecutor)
|
||||
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!;
|
||||
await result.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal concrete <see cref="ResponseAgentProvider"/> that exposes an injected
|
||||
/// <see cref="AIFunction"/> registry and records which function got invoked.
|
||||
/// Used by the framework-invoke approval branch (<c>InvokeRegisteredFunctionAsync</c>).
|
||||
/// </summary>
|
||||
private sealed class TestFunctionAgentProvider : ResponseAgentProvider
|
||||
{
|
||||
private readonly Action<string>? _onInvoke;
|
||||
private readonly Action<AIFunctionArguments>? _onInvokeArguments;
|
||||
|
||||
public TestFunctionAgentProvider(
|
||||
IEnumerable<AIFunction> functions,
|
||||
Action<string>? onInvoke = null,
|
||||
Action<AIFunctionArguments>? onInvokeArguments = null)
|
||||
{
|
||||
this._onInvoke = onInvoke;
|
||||
this._onInvokeArguments = onInvokeArguments;
|
||||
this.Functions = functions.Select(f => (AIFunction)new RecordingAIFunction(f, this)).ToList();
|
||||
}
|
||||
|
||||
internal void RecordInvocation(string name, AIFunctionArguments? arguments)
|
||||
{
|
||||
this._onInvoke?.Invoke(name);
|
||||
if (arguments is not null)
|
||||
{
|
||||
this._onInvokeArguments?.Invoke(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
|
||||
string agentId, string? agentVersion, string? conversationId,
|
||||
IEnumerable<ChatMessage>? messages, IDictionary<string, object?>? inputArguments,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override IAsyncEnumerable<ChatMessage> GetMessagesAsync(
|
||||
string conversationId, int? limit = null, string? after = null, string? before = null,
|
||||
bool newestFirst = false, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
private sealed class RecordingAIFunction(AIFunction inner, TestFunctionAgentProvider owner) : AIFunction
|
||||
{
|
||||
public override string Name => inner.Name;
|
||||
public override string Description => inner.Description;
|
||||
public override JsonElement JsonSchema => inner.JsonSchema;
|
||||
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
owner.RecordInvocation(inner.Name, arguments);
|
||||
return inner.InvokeAsync(arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task ExecuteTestAsync(InvokeFunctionTool model)
|
||||
@@ -645,33 +318,5 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
|
||||
return AssignParent<InvokeFunctionTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeFunctionTool CreateModelWithVariableFunctionName(string displayName, string variableName)
|
||||
{
|
||||
InvokeFunctionTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
FunctionName = new StringExpression.Builder(
|
||||
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
return AssignParent<InvokeFunctionTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeFunctionTool CreateModelWithVariableArgument(
|
||||
string displayName, string functionName, string argumentKey, string variableName)
|
||||
{
|
||||
InvokeFunctionTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
builder.Arguments.Add(argumentKey,
|
||||
ValueExpression.Variable(PropertyPath.TopicVariable(variableName)));
|
||||
return AssignParent<InvokeFunctionTool>(builder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -419,82 +419,6 @@ public class MagenticOrchestrationTests
|
||||
"final-answer synthesis must see what participants actually said");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Participant_Receives_Prior_Participant_Response_Not_InstructionAsync()
|
||||
{
|
||||
// Regression: each participant must see prior participants' *responses* (the running conversation),
|
||||
// not their *instructions*. Previously the orchestrator broadcast the per-round instruction to every
|
||||
// participant (untargeted fan-out) and never broadcast replies, so a later speaker received the earlier
|
||||
// speaker's instruction and never its answer.
|
||||
const string HealthInstruction = "HEALTH_CHECKER_INSTRUCTION_check_framework";
|
||||
const string DatabaseInstruction = "DATABASE_CHECKER_INSTRUCTION_check_database";
|
||||
const string HealthEchoPrefix = "HC_RESPONSE::";
|
||||
const string DatabaseEchoPrefix = "DB_RESPONSE::";
|
||||
|
||||
List<ChatMessage> facts = CreatePlanResponse("Facts");
|
||||
List<ChatMessage> plan = CreatePlanResponse("Plan");
|
||||
List<ChatMessage> round1Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: false,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "HealthChecker",
|
||||
instructionOrQuestion: HealthInstruction);
|
||||
List<ChatMessage> round2Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: false,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "DatabaseChecker",
|
||||
instructionOrQuestion: DatabaseInstruction);
|
||||
List<ChatMessage> round3Ledger = CreateProgressLedgerResponse(
|
||||
isRequestSatisfied: true,
|
||||
isInLoop: false,
|
||||
isProgressBeingMade: true,
|
||||
nextSpeaker: "DatabaseChecker",
|
||||
instructionOrQuestion: "Done");
|
||||
List<ChatMessage> finalAnswer = CreateFinalAnswerResponse("All systems checked");
|
||||
|
||||
TestReplayAgent manager = new(
|
||||
[facts, plan, round1Ledger, round2Ledger, round3Ledger, finalAnswer],
|
||||
name: "Manager");
|
||||
RecordingEchoAgent healthChecker = new(name: "HealthChecker", prefix: HealthEchoPrefix);
|
||||
RecordingEchoAgent databaseChecker = new(name: "DatabaseChecker", prefix: DatabaseEchoPrefix);
|
||||
|
||||
Workflow workflow = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(healthChecker, databaseChecker)
|
||||
.RequirePlanSignoff(false)
|
||||
.Build();
|
||||
|
||||
WorkflowRunResult runResult = await RunMagenticWorkflowAsync(
|
||||
workflow,
|
||||
[new ChatMessage(ChatRole.User, "Check system health")]);
|
||||
|
||||
runResult.Result.Should().NotBeNull();
|
||||
runResult.Result![0].Text.Should().Contain("All systems checked");
|
||||
|
||||
// Each participant takes exactly one turn.
|
||||
healthChecker.RecordedInputs.Should().ContainSingle();
|
||||
databaseChecker.RecordedInputs.Should().ContainSingle();
|
||||
|
||||
// The first speaker receives its own instruction.
|
||||
List<ChatMessage> healthInput = healthChecker.RecordedInputs[0];
|
||||
healthInput.Should().Contain(m => m.Text.Contains(HealthInstruction), "the first speaker receives its own instruction");
|
||||
|
||||
// The second speaker must see the first speaker's RESPONSE (authored by HealthChecker, carrying the echo
|
||||
// prefix that only the response — not the raw instruction — has), plus its own instruction.
|
||||
List<ChatMessage> databaseInput = databaseChecker.RecordedInputs[0];
|
||||
databaseInput.Should().Contain(
|
||||
m => m.AuthorName == "HealthChecker" && m.Text.Contains(HealthEchoPrefix),
|
||||
"the next speaker must receive the prior participant's response (the running conversation)");
|
||||
databaseInput.Should().Contain(m => m.Text.Contains(DatabaseInstruction),
|
||||
"the next speaker must receive its own instruction");
|
||||
|
||||
// The leaked-instruction bug: the second speaker must not receive HealthChecker's instruction as a
|
||||
// bare message (it should only appear, if at all, embedded in HealthChecker's prefixed response).
|
||||
databaseInput.Should().NotContain(
|
||||
m => m.AuthorName != "HealthChecker" && m.Text.Trim() == HealthInstruction,
|
||||
"the prior speaker's instruction must not leak into the next speaker's context as a standalone message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlanReview_Revised_Triggers_ReplanAsync()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user