mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d83bc07582 | ||
|
|
4b0aeb76a5 | ||
|
|
c0ea099bd8 | ||
|
|
3498f9dc66 | ||
|
|
564259a4aa | ||
|
|
3753d938f5 | ||
|
|
60cc5ee4e4 | ||
|
|
dd29f9aa65 | ||
|
|
a5f4e0078e | ||
|
|
3c0c12cd46 | ||
|
|
8dde9ef627 | ||
|
|
93cbf6b3f0 | ||
|
|
9a56bc9f16 | ||
|
|
cea83bd8d5 | ||
|
|
7ae73a68d6 | ||
|
|
3daed114ee | ||
|
|
5e097276a0 | ||
|
|
383d551b86 |
@@ -88,6 +88,7 @@ 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"
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review"
|
||||
with:
|
||||
script: |
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
|
||||
|
||||
@@ -206,6 +206,7 @@ temp*/
|
||||
.temp/
|
||||
|
||||
# AI
|
||||
**/.checkpoints/
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
@@ -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.
|
||||
|
||||
For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception.
|
||||
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.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
|
||||
<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.6" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.8" />
|
||||
<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.6" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
|
||||
<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.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.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.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.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
|
||||
<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.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<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" />
|
||||
|
||||
@@ -344,6 +344,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-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.9.0</VersionPrefix>
|
||||
<VersionPrefix>1.10.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260603</DateSuffix>
|
||||
<DateSuffix>260610</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.9.0</GitTag>
|
||||
<GitTag>1.10.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+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.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageReference Include="Neo4j.AgentFramework.GraphRAG" Version="0.1.0-preview.2" />
|
||||
<PackageReference Include="Neo4j.Driver" Version="5.28.0" />
|
||||
</ItemGroup>
|
||||
|
||||
+28
@@ -71,6 +71,34 @@ 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,3 +107,29 @@ 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,6 +174,34 @@ 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,6 +104,32 @@ 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,6 +112,34 @@ 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,6 +107,32 @@ 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,6 +108,34 @@ 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,6 +78,40 @@ 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,6 +139,34 @@ 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,6 +104,34 @@ 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,6 +111,34 @@ 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
@@ -0,0 +1,16 @@
|
||||
# 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
@@ -0,0 +1,17 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# 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
@@ -0,0 +1,33 @@
|
||||
<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
@@ -0,0 +1,145 @@
|
||||
// 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
@@ -0,0 +1,197 @@
|
||||
# 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
@@ -0,0 +1,48 @@
|
||||
# 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
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-toolbox-auth-paths
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,21 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolbox MCP tools.
|
||||
//
|
||||
// Demonstrates how to register one or more Foundry toolsets so the agent can
|
||||
// Demonstrates how to register one or more Foundry toolboxes so the agent can
|
||||
// call tools provided by the Foundry platform's managed MCP proxy.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// 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_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:
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
@@ -28,10 +34,13 @@ using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
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("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolbox";
|
||||
|
||||
// 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).
|
||||
@@ -45,12 +54,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 Toolset.
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolbox.
|
||||
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 Toolset MCP tools");
|
||||
description: "Hosted agent backed by Foundry Toolbox MCP tools");
|
||||
|
||||
// ── Build the host ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,8 +70,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 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
|
||||
// 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
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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,6 +98,34 @@ 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,6 +121,34 @@ 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.
|
||||
|
||||
+30
-2
@@ -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., `gpt-4o`)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
|
||||
- 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=gpt-4o
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=hosted-workflow-simple
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
@@ -104,6 +104,34 @@ 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.
|
||||
|
||||
+87
-10
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -13,24 +14,32 @@ 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://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>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>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://cognitiveservices.azure.com/.default"]);
|
||||
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";
|
||||
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly string? _featuresHeaderValue;
|
||||
private readonly string? _additionalFeaturesHeaderValue;
|
||||
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? additionalFeaturesHeaderValue)
|
||||
{
|
||||
this._credential = credential;
|
||||
this._featuresHeaderValue = featuresHeaderValue;
|
||||
this._additionalFeaturesHeaderValue = additionalFeaturesHeaderValue;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
@@ -43,10 +52,9 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
|
||||
}
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue));
|
||||
|
||||
PropagateTraceContext(request);
|
||||
|
||||
// MaxRetries is the total number of attempts (not additional retries after the first).
|
||||
for (int attempt = 0; attempt < MaxRetries; attempt++)
|
||||
@@ -82,6 +90,75 @@ 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)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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,14 +16,15 @@ 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_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// per <c>tools-integration-spec.md</c> §2–§3.
|
||||
/// </summary>
|
||||
public IList<string> ToolboxNames { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
|
||||
/// Gets or sets the Toolboxes API version to use when constructing proxy URLs.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "2025-05-01-preview";
|
||||
public string ApiVersion { get; set; } = "v1";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
|
||||
@@ -36,7 +37,9 @@ public sealed class FoundryToolboxOptions
|
||||
public bool StrictMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
|
||||
/// 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>.
|
||||
/// Not part of the public API.
|
||||
/// </summary>
|
||||
internal string? EndpointOverride { get; set; }
|
||||
|
||||
@@ -24,7 +24,13 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
|
||||
/// 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
|
||||
/// no tools are registered, keeping the container healthy per spec §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -56,6 +62,24 @@ 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>
|
||||
@@ -75,16 +99,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
/// <inheritdoc/>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this._resolvedEndpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
// 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");
|
||||
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
if (string.IsNullOrEmpty(projectEndpoint))
|
||||
{
|
||||
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
|
||||
this._logger.LogWarning(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is 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";
|
||||
@@ -93,10 +125,12 @@ 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)
|
||||
@@ -121,10 +155,16 @@ 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>
|
||||
@@ -165,7 +205,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
}
|
||||
|
||||
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -192,7 +232,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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,
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectSharedRedaction>true</InjectSharedRedaction>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ 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;
|
||||
@@ -49,6 +51,7 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
@@ -84,6 +87,7 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
@@ -109,10 +113,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_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1</c>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// no tools are loaded (the container remains healthy per spec §2).
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -167,12 +171,61 @@ 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>
|
||||
@@ -180,9 +233,37 @@ 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>
|
||||
|
||||
+49
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
@@ -32,4 +32,52 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Auto-generated at pack time by _GenerateBuildTransitiveProps in the csproj.
|
||||
Microsoft.Agents.AI.GitHub.Copilot.props
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@@ -1,10 +1,14 @@
|
||||
// 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;
|
||||
@@ -16,6 +20,7 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
{
|
||||
private readonly IChannelHandler _channelHandler;
|
||||
private readonly IPurviewClient _purviewClient;
|
||||
private readonly ICacheProvider _cacheProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,12 +28,14 @@ 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, ILogger logger, PurviewSettings purviewSettings)
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings)
|
||||
{
|
||||
this._channelHandler = channelHandler;
|
||||
this._purviewClient = purviewClient;
|
||||
this._cacheProvider = cacheProvider;
|
||||
this._logger = logger;
|
||||
|
||||
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
|
||||
@@ -67,6 +74,28 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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,4 +53,10 @@ 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,6 +130,11 @@ 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.
|
||||
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.
|
||||
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.
|
||||
|
||||
## Exceptions
|
||||
| Exception | Scenario |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
@@ -193,43 +194,60 @@ 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)
|
||||
{
|
||||
psResponse = cacheResponse;
|
||||
}
|
||||
else
|
||||
{
|
||||
psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
|
||||
return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
|
||||
}
|
||||
catch (PurviewJobException)
|
||||
{
|
||||
// QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
@@ -238,6 +256,30 @@ 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>
|
||||
@@ -248,9 +290,21 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
if (actionInfos?.Count > 0)
|
||||
{
|
||||
pcResponse.PolicyActions = pcResponse.PolicyActions is null ?
|
||||
actionInfos :
|
||||
[.. pcResponse.PolicyActions, .. actionInfos];
|
||||
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;
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
@@ -262,7 +316,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>
|
||||
private static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
internal static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
{
|
||||
ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
|
||||
|
||||
@@ -284,7 +338,11 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
|
||||
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
|
||||
{
|
||||
locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
|
||||
if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locationMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (activityMatch && locationMatch)
|
||||
|
||||
@@ -18,6 +18,8 @@ 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, McpClient> _clients = [];
|
||||
private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
|
||||
@@ -66,16 +66,15 @@ 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, cancellationToken).ConfigureAwait(false);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, 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, cancellationToken).ConfigureAwait(false);
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
|
||||
@@ -145,10 +144,11 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
|
||||
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
|
||||
string trimmedUrl = serverUrl.Trim();
|
||||
var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, 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(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
|
||||
McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false);
|
||||
this._clients[clientCacheKey] = newClient;
|
||||
return newClient;
|
||||
}
|
||||
@@ -168,6 +168,19 @@ 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,
|
||||
@@ -185,7 +198,12 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
|
||||
{
|
||||
httpClient = new 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);
|
||||
this._ownedHttpClients[httpClientCacheKey] = httpClient;
|
||||
}
|
||||
|
||||
@@ -202,26 +220,50 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
/// <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)
|
||||
{
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
{
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
|
||||
sorted[header.Key.ToLowerInvariant()] = header.Value;
|
||||
}
|
||||
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
StringBuilder payload = new();
|
||||
foreach (KeyValuePair<string, string> kvp in sorted)
|
||||
{
|
||||
payload.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
#pragma warning restore CA1308
|
||||
|
||||
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
|
||||
+65
-3
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -27,6 +28,13 @@ 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>
|
||||
@@ -69,6 +77,10 @@ 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));
|
||||
}
|
||||
|
||||
@@ -155,6 +167,31 @@ 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>
|
||||
@@ -262,7 +299,24 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string functionName = this.GetFunctionName();
|
||||
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();
|
||||
}
|
||||
|
||||
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
|
||||
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
|
||||
|
||||
@@ -275,8 +329,7 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
};
|
||||
}
|
||||
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments.NormalizePortableValues());
|
||||
|
||||
object? result;
|
||||
try
|
||||
@@ -341,4 +394,13 @@ 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);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.MapGet("/readiness", () => Results.Ok());
|
||||
app.Run();
|
||||
|
||||
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// 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";
|
||||
}
|
||||
+126
-7
@@ -1,6 +1,9 @@
|
||||
// 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;
|
||||
@@ -51,28 +54,144 @@ public class FoundryToolboxBearerTokenHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync()
|
||||
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()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Contains("feature1,feature2", values);
|
||||
var header = values.Single();
|
||||
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature1", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature2", header, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync()
|
||||
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
// Override already contains the mandatory flag — must not be duplicated in the merged value.
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.False(request.Headers.Contains("Foundry-Features"));
|
||||
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]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
+155
-5
@@ -9,6 +9,7 @@ using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -39,15 +40,17 @@ public class FoundryToolboxServiceTests
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
|
||||
{
|
||||
// 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);
|
||||
// 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);
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
@@ -59,10 +62,157 @@ 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_AGENT_TOOLSET_ENDPOINT", saved);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -2,9 +2,15 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -135,4 +141,73 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
+197
-81
@@ -10,57 +10,86 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public class GitHubCopilotAgentTests
|
||||
{
|
||||
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
|
||||
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 static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
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();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("4", response.Text);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("4", response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
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();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number."))
|
||||
try
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.", session))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
string fullText = string.Join("", updates.Select(u => u.Text));
|
||||
Assert.Contains("4", fullText);
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
string fullText = string.Join("", updates.Select(u => u.Text));
|
||||
Assert.Contains("4", fullText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithFunctionTool_InvokesToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
bool toolInvoked = false;
|
||||
|
||||
AIFunction weatherTool = AIFunctionFactory.Create((string location) =>
|
||||
@@ -72,24 +101,42 @@ public class GitHubCopilotAgentTests
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
await using GitHubCopilotAgent agent = new(
|
||||
client,
|
||||
tools: [weatherTool],
|
||||
instructions: "You are a helpful weather agent. Use the GetWeather tool to answer weather questions.");
|
||||
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.",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?");
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.True(toolInvoked);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSession_MaintainsContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -99,23 +146,32 @@ public class GitHubCopilotAgentTests
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act - First turn
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
|
||||
Assert.NotNull(response1);
|
||||
try
|
||||
{
|
||||
// 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);
|
||||
// Assert
|
||||
Assert.NotNull(response2);
|
||||
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSessionResume_ContinuesConversationAsync()
|
||||
{
|
||||
// Arrange - First agent instance starts a conversation
|
||||
string? sessionId;
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
string? sessionId = null;
|
||||
|
||||
await using CopilotClient client1 = new(new CopilotClientOptions());
|
||||
await client1.StartAsync();
|
||||
@@ -125,31 +181,44 @@ 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);
|
||||
|
||||
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
|
||||
Assert.NotNull(sessionId);
|
||||
try
|
||||
{
|
||||
await agent1.RunAsync("Remember this number: 42.", session1);
|
||||
|
||||
// Act - Second agent instance resumes the session
|
||||
await using CopilotClient client2 = new(new CopilotClientOptions());
|
||||
await client2.StartAsync();
|
||||
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
|
||||
Assert.NotNull(sessionId);
|
||||
|
||||
await using GitHubCopilotAgent agent2 = new(
|
||||
client2,
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
// Act - Second agent instance resumes the session
|
||||
await using CopilotClient client2 = new(new CopilotClientOptions());
|
||||
await client2.StartAsync();
|
||||
|
||||
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
|
||||
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
|
||||
await using GitHubCopilotAgent agent2 = new(
|
||||
client2,
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("42", response.Text);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -159,20 +228,30 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithUrlPermissions_FetchesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -182,20 +261,30 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence");
|
||||
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);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -214,20 +303,31 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("List the files in the current directory");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("List the files in the current directory", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.NotEmpty(response.Text);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.NotEmpty(response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
[Trait("Category", "IntegrationDisabled")]
|
||||
public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -245,12 +345,28 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@ 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()
|
||||
{
|
||||
@@ -530,6 +548,7 @@ 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)
|
||||
{
|
||||
@@ -547,6 +566,11 @@ 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)
|
||||
{
|
||||
|
||||
+358
-40
@@ -3,12 +3,14 @@
|
||||
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;
|
||||
@@ -50,10 +52,6 @@ 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 =
|
||||
@@ -70,8 +68,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -109,10 +107,6 @@ 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 =
|
||||
@@ -129,8 +123,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -168,10 +162,6 @@ 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 =
|
||||
@@ -188,8 +178,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -213,6 +203,99 @@ 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()
|
||||
{
|
||||
@@ -279,12 +362,9 @@ 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()
|
||||
@@ -299,8 +379,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -336,10 +416,6 @@ 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 =
|
||||
@@ -355,8 +431,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -432,13 +508,9 @@ 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
|
||||
@@ -467,13 +539,9 @@ 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
|
||||
@@ -484,10 +552,260 @@ 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")
|
||||
|
||||
+183
@@ -321,6 +321,189 @@ 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]
|
||||
|
||||
+355
@@ -1,11 +1,21 @@
|
||||
// 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;
|
||||
|
||||
@@ -261,6 +271,323 @@ 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)
|
||||
@@ -318,5 +645,33 @@ 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
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ agent_framework/
|
||||
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
|
||||
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument.
|
||||
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins.
|
||||
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
|
||||
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
|
||||
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
|
||||
- `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel.
|
||||
|
||||
@@ -124,7 +124,7 @@ from ._harness._todo import (
|
||||
TodoSessionStore,
|
||||
TodoStore,
|
||||
)
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool, SamplingApprovalCallback
|
||||
from ._middleware import (
|
||||
AgentContext,
|
||||
AgentMiddleware,
|
||||
@@ -472,6 +472,7 @@ __all__ = [
|
||||
"RunContext",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SamplingApprovalCallback",
|
||||
"SecretString",
|
||||
"SelectiveToolCallCompactionStrategy",
|
||||
"SessionContext",
|
||||
|
||||
@@ -66,23 +66,45 @@ def _assemble_instructions(
|
||||
def _assemble_compaction_provider(
|
||||
*,
|
||||
disable_compaction: bool,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
max_context_window_tokens: int | None,
|
||||
max_output_tokens: int | None,
|
||||
history_source_id: str,
|
||||
before_compaction_strategy: CompactionStrategy | None,
|
||||
after_compaction_strategy: CompactionStrategy | None,
|
||||
tokenizer: TokenizerProtocol | None,
|
||||
) -> CompactionProvider | None:
|
||||
"""Build the compaction provider from parameters or defaults."""
|
||||
"""Build the compaction provider from parameters or defaults.
|
||||
|
||||
The token-budget defaults (``ContextWindowCompactionStrategy`` for the before phase and
|
||||
``ToolResultCompactionStrategy`` for the after phase) are only applied when the token
|
||||
params are provided. Caller-supplied strategies are always honored. Either phase may end
|
||||
up ``None``, which ``CompactionProvider`` interprets as "skip that phase".
|
||||
|
||||
Returns None when compaction is explicitly disabled, or when neither phase has a strategy
|
||||
(no custom strategies and no token budget to build the defaults).
|
||||
"""
|
||||
if disable_compaction:
|
||||
return None
|
||||
|
||||
before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
|
||||
# Resolve the before-strategy: custom strategy wins; otherwise fall back to the
|
||||
# token-budget-aware default when token params are available.
|
||||
before_strategy = before_compaction_strategy
|
||||
if before_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
|
||||
before_strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Resolve the after-strategy: custom strategy wins; otherwise fall back to the default
|
||||
# when token params are available.
|
||||
after_strategy = after_compaction_strategy
|
||||
if after_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
|
||||
after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
|
||||
|
||||
# Nothing to compact in either phase: skip the provider entirely.
|
||||
if before_strategy is None and after_strategy is None:
|
||||
return None
|
||||
|
||||
return CompactionProvider(
|
||||
before_strategy=before_strategy,
|
||||
@@ -157,8 +179,8 @@ def create_harness_agent(
|
||||
harness_instructions: str | None = None,
|
||||
agent_instructions: str | None = None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
history_provider: HistoryProvider | None = None,
|
||||
disable_compaction: bool = False,
|
||||
before_compaction_strategy: CompactionStrategy | None = None,
|
||||
@@ -206,8 +228,6 @@ def create_harness_agent(
|
||||
|
||||
agent = create_harness_agent(
|
||||
OpenAIChatClient(model="gpt-4o"),
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
session = agent.create_session()
|
||||
response = await agent.run("Plan a weekend trip to Seattle", session=session)
|
||||
@@ -243,13 +263,21 @@ def create_harness_agent(
|
||||
(e.g., "You are a research assistant focused on academic sources.").
|
||||
tools: Additional tools to include in the agent's toolset.
|
||||
max_context_window_tokens: Maximum tokens the model's context window supports.
|
||||
Used to construct the default token-budget-aware compaction strategies. When None
|
||||
(default) and no custom ``before_compaction_strategy`` / ``after_compaction_strategy``
|
||||
is provided, compaction is automatically disabled.
|
||||
max_output_tokens: Maximum output tokens per response.
|
||||
Used to construct the default compaction strategies and sets a default max_tokens
|
||||
chat option. When None (default), no default max_tokens option is set, and unless a
|
||||
custom compaction strategy is provided, compaction is automatically disabled.
|
||||
history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used.
|
||||
disable_compaction: When True, skip compaction provider setup.
|
||||
before_compaction_strategy: Custom before-run compaction strategy.
|
||||
Defaults to ContextWindowCompactionStrategy (token-budget aware).
|
||||
after_compaction_strategy: Custom after-run compaction strategy.
|
||||
Defaults to ToolResultCompactionStrategy.
|
||||
before_compaction_strategy: Custom before-run compaction strategy. When provided,
|
||||
compaction runs even if token params are omitted. Defaults to
|
||||
ContextWindowCompactionStrategy (token-budget aware) when token params are provided.
|
||||
after_compaction_strategy: Custom after-run compaction strategy. When provided,
|
||||
compaction runs even if token params are omitted. Defaults to
|
||||
ToolResultCompactionStrategy when token params are provided.
|
||||
tokenizer: Custom tokenizer for compaction strategies.
|
||||
disable_todo: When True, skip the TodoProvider.
|
||||
todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True.
|
||||
@@ -283,14 +311,19 @@ def create_harness_agent(
|
||||
A fully configured :class:`~agent_framework.Agent` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0
|
||||
or max_output_tokens >= max_context_window_tokens.
|
||||
ValueError: If max_context_window_tokens is provided and <= 0, or
|
||||
max_output_tokens is provided and <= 0, or max_output_tokens >=
|
||||
max_context_window_tokens when both are provided.
|
||||
"""
|
||||
if max_context_window_tokens <= 0:
|
||||
if max_context_window_tokens is not None and max_context_window_tokens <= 0:
|
||||
raise ValueError("max_context_window_tokens must be positive.")
|
||||
if max_output_tokens < 0:
|
||||
raise ValueError("max_output_tokens must be non-negative.")
|
||||
if max_output_tokens >= max_context_window_tokens:
|
||||
if max_output_tokens is not None and max_output_tokens <= 0:
|
||||
raise ValueError("max_output_tokens must be positive.")
|
||||
if (
|
||||
max_context_window_tokens is not None
|
||||
and max_output_tokens is not None
|
||||
and max_output_tokens >= max_context_window_tokens
|
||||
):
|
||||
raise ValueError("max_output_tokens must be less than max_context_window_tokens.")
|
||||
|
||||
# Build history provider.
|
||||
@@ -347,7 +380,8 @@ def create_harness_agent(
|
||||
|
||||
# Build default options dict.
|
||||
default_opts: dict[str, Any] = dict(default_options) if default_options else {}
|
||||
default_opts.setdefault("max_tokens", max_output_tokens)
|
||||
if max_output_tokens is not None:
|
||||
default_opts.setdefault("max_tokens", max_output_tokens)
|
||||
|
||||
agent = Agent(
|
||||
client,
|
||||
|
||||
@@ -16,6 +16,7 @@ from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ig
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
|
||||
|
||||
from opentelemetry import propagate
|
||||
@@ -99,6 +100,22 @@ _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextV
|
||||
MCP_DEFAULT_TIMEOUT = 30
|
||||
MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5
|
||||
|
||||
# Default safety limits applied to server-initiated MCP sampling requests
|
||||
# (``sampling/createMessage``). MCP servers are untrusted third parties, so the
|
||||
# default ``sampling_callback`` denies requests unless an approval callback is
|
||||
# supplied, and bounds the cost of any approved request.
|
||||
# - ``_DEFAULT_SAMPLING_MAX_TOKENS`` clamps the server-requested ``maxTokens``.
|
||||
# - ``_DEFAULT_SAMPLING_MAX_REQUESTS`` caps the number of sampling requests per
|
||||
# session connection (the counter resets on reconnect).
|
||||
_DEFAULT_SAMPLING_MAX_TOKENS = 4096
|
||||
_DEFAULT_SAMPLING_MAX_REQUESTS = 25
|
||||
|
||||
# A user-supplied gate invoked before each server-initiated sampling request is
|
||||
# forwarded to the chat client. It receives the raw ``CreateMessageRequestParams``
|
||||
# and returns (or awaits to) a truthy value to approve the request or a falsy
|
||||
# value to deny it. Both synchronous and asynchronous callables are supported.
|
||||
SamplingApprovalCallback = Callable[["types.CreateMessageRequestParams"], "bool | Coroutine[Any, Any, bool]"]
|
||||
|
||||
# region: Helpers
|
||||
|
||||
LOG_LEVEL_MAPPING: dict[str, int] = {
|
||||
@@ -345,6 +362,9 @@ class MCPTool:
|
||||
session: ClientSession | None = None,
|
||||
request_timeout: int | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
task_options: MCPTaskOptions | None = None,
|
||||
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
|
||||
@@ -378,6 +398,20 @@ class MCPTool:
|
||||
session: An existing MCP client session to use.
|
||||
request_timeout: Timeout in seconds for MCP requests.
|
||||
client: A chat client for sampling callbacks.
|
||||
sampling_approval_callback: Optional gate invoked before each server-initiated
|
||||
``sampling/createMessage`` request is forwarded to ``client``. It receives the
|
||||
raw ``CreateMessageRequestParams`` and may be synchronous or asynchronous;
|
||||
returning a truthy value approves the request and a falsy value denies it. When
|
||||
``None`` (the default), every sampling request is **denied** because MCP servers
|
||||
are untrusted third parties (confused-deputy risk). To restore the legacy
|
||||
auto-approve behavior, pass ``lambda params: True`` as an explicit, conscious
|
||||
opt-in.
|
||||
sampling_max_tokens: Upper bound applied to the server-requested ``maxTokens`` for an
|
||||
approved sampling request. The effective value is ``min(requested, cap)``. Set to
|
||||
``None`` to disable the cap. Defaults to ``_DEFAULT_SAMPLING_MAX_TOKENS``.
|
||||
sampling_max_requests: Maximum number of sampling requests allowed per session
|
||||
connection; further requests are rejected. The counter resets on reconnect. Set
|
||||
to ``None`` to disable the limit. Defaults to ``_DEFAULT_SAMPLING_MAX_REQUESTS``.
|
||||
additional_properties: Additional properties for the tool.
|
||||
task_options: Options controlling how long-running MCP tasks are driven for
|
||||
tools that advertise ``execution.taskSupport == "required"``. When ``None``,
|
||||
@@ -410,6 +444,10 @@ class MCPTool:
|
||||
self.session = session
|
||||
self.request_timeout = request_timeout
|
||||
self.client = client
|
||||
self.sampling_approval_callback = sampling_approval_callback
|
||||
self.sampling_max_tokens = sampling_max_tokens
|
||||
self.sampling_max_requests = sampling_max_requests
|
||||
self._sampling_request_count = 0
|
||||
self._functions: list[FunctionTool] = []
|
||||
self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
|
||||
self._tool_task_support_by_name: dict[str, str] = {}
|
||||
@@ -539,6 +577,9 @@ class MCPTool:
|
||||
case _:
|
||||
result.append(Content.from_text(str(item)))
|
||||
|
||||
if mcp_type.structuredContent is not None:
|
||||
result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str)))
|
||||
|
||||
if not result:
|
||||
result.append(Content.from_text("null"))
|
||||
return result
|
||||
@@ -840,6 +881,7 @@ class MCPTool:
|
||||
self._supports_prompts = True
|
||||
self._supports_logging = None
|
||||
self._ping_available = True
|
||||
self._sampling_request_count = 0
|
||||
|
||||
def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None:
|
||||
self._server_capabilities = capabilities
|
||||
@@ -994,6 +1036,49 @@ class MCPTool:
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to set log level to %s", logger.level, exc_info=exc)
|
||||
|
||||
async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool:
|
||||
"""Run the configured sampling approval gate.
|
||||
|
||||
Returns ``True`` only when an approval callback is configured and approves the request.
|
||||
When no callback is set, the request is denied (safe default for untrusted servers).
|
||||
"""
|
||||
callback = self.sampling_approval_callback
|
||||
if callback is None:
|
||||
logger.warning(
|
||||
"Denying MCP sampling request from '%s': no 'sampling_approval_callback' configured.",
|
||||
self.name,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
outcome = callback(params)
|
||||
if isawaitable(outcome):
|
||||
outcome = await outcome
|
||||
except Exception as ex:
|
||||
logger.warning(
|
||||
"Denying MCP sampling request from '%s': approval callback raised %s.",
|
||||
self.name,
|
||||
ex,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
approved = bool(outcome)
|
||||
if not approved:
|
||||
logger.warning("MCP sampling request from '%s' was denied by the approval callback.", self.name)
|
||||
return approved
|
||||
|
||||
def _capped_sampling_max_tokens(self, requested: int) -> int:
|
||||
"""Clamp the server-requested ``maxTokens`` to ``sampling_max_tokens`` when configured."""
|
||||
cap = self.sampling_max_tokens
|
||||
if cap is not None and requested > cap:
|
||||
logger.warning(
|
||||
"Capping MCP sampling maxTokens for '%s' from %d to %d.",
|
||||
self.name,
|
||||
requested,
|
||||
cap,
|
||||
)
|
||||
return cap
|
||||
return requested
|
||||
|
||||
async def sampling_callback(
|
||||
self,
|
||||
context: RequestContext[ClientSession, Any],
|
||||
@@ -1001,20 +1086,32 @@ class MCPTool:
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
"""Callback function for sampling.
|
||||
|
||||
This function is called when the MCP server needs to get a message completed.
|
||||
It uses the configured chat client to generate responses.
|
||||
This function is called when the MCP server sends a ``sampling/createMessage``
|
||||
request. It enforces safety guardrails and, if the request is approved, uses the
|
||||
configured chat client to generate a response.
|
||||
|
||||
Safety:
|
||||
MCP servers are untrusted third parties, so forwarding server-controlled prompts
|
||||
to the chat client without review is a confused-deputy risk. This callback
|
||||
therefore applies, in order: a per-session rate limit
|
||||
(``sampling_max_requests``), an approval gate (``sampling_approval_callback``,
|
||||
which **denies by default** when not configured), and a ``maxTokens`` cap
|
||||
(``sampling_max_tokens``). To allow sampling, pass a ``sampling_approval_callback``
|
||||
that returns a truthy value (use ``lambda params: True`` to auto-approve as an
|
||||
explicit opt-in).
|
||||
|
||||
Note:
|
||||
This is a simple version of this function. It can be overridden to allow
|
||||
more complex sampling. It gets added to the session at initialization time,
|
||||
so overriding it is the best way to customize this behavior.
|
||||
This is the default implementation. It can be overridden to allow more complex
|
||||
sampling. It gets added to the session at initialization time, so overriding it is
|
||||
the best way to customize this behavior.
|
||||
|
||||
Args:
|
||||
context: The request context from the MCP server.
|
||||
params: The message creation request parameters.
|
||||
|
||||
Returns:
|
||||
Either a CreateMessageResult with the generated message or ErrorData if generation fails.
|
||||
Either a CreateMessageResult with the generated message or ErrorData if the request
|
||||
is denied, rate limited, or generation fails.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
@@ -1023,7 +1120,38 @@ class MCPTool:
|
||||
code=types.INTERNAL_ERROR,
|
||||
message="No chat client available. Please set a chat client.",
|
||||
)
|
||||
logger.debug("Sampling callback called with params: %s", params)
|
||||
|
||||
logger.warning(
|
||||
"MCP server '%s' sent a sampling/createMessage request (%d message(s), maxTokens=%s).",
|
||||
self.name,
|
||||
len(params.messages),
|
||||
params.maxTokens,
|
||||
)
|
||||
|
||||
if self.sampling_max_requests is not None:
|
||||
if self._sampling_request_count >= self.sampling_max_requests:
|
||||
logger.warning(
|
||||
"Denying MCP sampling request from '%s': per-session limit of %d reached.",
|
||||
self.name,
|
||||
self.sampling_max_requests,
|
||||
)
|
||||
return types.ErrorData(
|
||||
code=types.INVALID_REQUEST,
|
||||
message="Sampling rate limit exceeded for this MCP session.",
|
||||
)
|
||||
self._sampling_request_count += 1
|
||||
|
||||
if not await self._sampling_request_approved(params):
|
||||
if self.sampling_approval_callback is None:
|
||||
message = (
|
||||
"Sampling request denied. MCP sampling is disabled by default for untrusted "
|
||||
"servers; provide a 'sampling_approval_callback' that approves the request to "
|
||||
"enable it."
|
||||
)
|
||||
else:
|
||||
message = "Sampling request denied by the 'sampling_approval_callback'."
|
||||
return types.ErrorData(code=types.INVALID_REQUEST, message=message)
|
||||
|
||||
messages: list[Message] = []
|
||||
for msg in params.messages:
|
||||
messages.append(self._parse_message_from_mcp(msg))
|
||||
@@ -1045,7 +1173,7 @@ class MCPTool:
|
||||
|
||||
if params.temperature is not None:
|
||||
options["temperature"] = params.temperature
|
||||
options["max_tokens"] = params.maxTokens
|
||||
options["max_tokens"] = self._capped_sampling_max_tokens(params.maxTokens)
|
||||
if params.stopSequences is not None:
|
||||
options["stop"] = params.stopSequences
|
||||
|
||||
@@ -2219,6 +2347,9 @@ class MCPStdioTool(MCPTool):
|
||||
env: dict[str, str] | None = None,
|
||||
encoding: str | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
task_options: MCPTaskOptions | None = None,
|
||||
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
|
||||
@@ -2266,6 +2397,16 @@ class MCPStdioTool(MCPTool):
|
||||
env: The environment variables to set for the command.
|
||||
encoding: The encoding to use for the command output.
|
||||
client: The chat client to use for sampling.
|
||||
sampling_approval_callback: Optional gate run before each server-initiated
|
||||
``sampling/createMessage`` request reaches ``client``. Receives the raw
|
||||
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
|
||||
request, a falsy return denies it. When ``None`` (the default) every sampling
|
||||
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
|
||||
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
|
||||
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
|
||||
(``min(requested, cap)``); ``None`` disables it.
|
||||
sampling_max_requests: Per-session cap on the number of sampling requests; further
|
||||
requests are rejected. Resets on reconnect. ``None`` disables it.
|
||||
task_options: Options for tools that advertise
|
||||
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
|
||||
additional_tool_argument_names: Extra argument names to forward to the MCP server in
|
||||
@@ -2300,6 +2441,9 @@ class MCPStdioTool(MCPTool):
|
||||
request_timeout=request_timeout,
|
||||
task_options=task_options,
|
||||
additional_tool_argument_names=additional_tool_argument_names,
|
||||
sampling_approval_callback=sampling_approval_callback,
|
||||
sampling_max_tokens=sampling_max_tokens,
|
||||
sampling_max_requests=sampling_max_requests,
|
||||
)
|
||||
self.command = command
|
||||
self.args = args or []
|
||||
@@ -2375,6 +2519,9 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
allowed_tools: Collection[str] | None = None,
|
||||
terminate_on_close: bool | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
http_client: AsyncClient | None = None,
|
||||
header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None,
|
||||
@@ -2423,6 +2570,16 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
additional_properties: Additional properties.
|
||||
terminate_on_close: Close the transport when the MCP client is terminated.
|
||||
client: The chat client to use for sampling.
|
||||
sampling_approval_callback: Optional gate run before each server-initiated
|
||||
``sampling/createMessage`` request reaches ``client``. Receives the raw
|
||||
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
|
||||
request, a falsy return denies it. When ``None`` (the default) every sampling
|
||||
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
|
||||
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
|
||||
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
|
||||
(``min(requested, cap)``); ``None`` disables it.
|
||||
sampling_max_requests: Per-session cap on the number of sampling requests; further
|
||||
requests are rejected. Resets on reconnect. ``None`` disables it.
|
||||
http_client: Optional asyncClient to use. If not provided, the
|
||||
``streamable_http_client`` API will create and manage a default client.
|
||||
To configure headers, timeouts, or other HTTP client settings, create
|
||||
@@ -2466,6 +2623,9 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
request_timeout=request_timeout,
|
||||
task_options=task_options,
|
||||
additional_tool_argument_names=additional_tool_argument_names,
|
||||
sampling_approval_callback=sampling_approval_callback,
|
||||
sampling_max_tokens=sampling_max_tokens,
|
||||
sampling_max_requests=sampling_max_requests,
|
||||
)
|
||||
self.url = url
|
||||
self.terminate_on_close = terminate_on_close
|
||||
@@ -2590,6 +2750,9 @@ class MCPWebsocketTool(MCPTool):
|
||||
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
|
||||
allowed_tools: Collection[str] | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
task_options: MCPTaskOptions | None = None,
|
||||
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
|
||||
@@ -2635,6 +2798,16 @@ class MCPWebsocketTool(MCPTool):
|
||||
allowed_tools: A list of tools that are allowed to use this tool.
|
||||
additional_properties: Additional properties.
|
||||
client: The chat client to use for sampling.
|
||||
sampling_approval_callback: Optional gate run before each server-initiated
|
||||
``sampling/createMessage`` request reaches ``client``. Receives the raw
|
||||
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
|
||||
request, a falsy return denies it. When ``None`` (the default) every sampling
|
||||
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
|
||||
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
|
||||
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
|
||||
(``min(requested, cap)``); ``None`` disables it.
|
||||
sampling_max_requests: Per-session cap on the number of sampling requests; further
|
||||
requests are rejected. Resets on reconnect. ``None`` disables it.
|
||||
task_options: Options for tools that advertise
|
||||
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
|
||||
additional_tool_argument_names: Extra argument names to forward to the MCP server in
|
||||
@@ -2669,6 +2842,9 @@ class MCPWebsocketTool(MCPTool):
|
||||
request_timeout=request_timeout,
|
||||
task_options=task_options,
|
||||
additional_tool_argument_names=additional_tool_argument_names,
|
||||
sampling_approval_callback=sampling_approval_callback,
|
||||
sampling_max_tokens=sampling_max_tokens,
|
||||
sampling_max_requests=sampling_max_requests,
|
||||
)
|
||||
self.url = url
|
||||
self._client_kwargs = kwargs
|
||||
|
||||
@@ -3516,9 +3516,7 @@ class MCPSkill(Skill):
|
||||
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
|
||||
text = _mcp_join_text(result)
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
|
||||
)
|
||||
raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.")
|
||||
self._content = text
|
||||
return text
|
||||
|
||||
@@ -3572,11 +3570,7 @@ class MCPSkill(Skill):
|
||||
or ``None`` if the name is unsafe.
|
||||
"""
|
||||
normalized = name.replace("\\", "/")
|
||||
if (
|
||||
normalized.startswith("/")
|
||||
or "://" in normalized
|
||||
or any(seg == ".." for seg in normalized.split("/"))
|
||||
):
|
||||
if normalized.startswith("/") or "://" in normalized or any(seg == ".." for seg in normalized.split("/")):
|
||||
logger.debug("Rejecting resource name with unsafe path components: %r", name)
|
||||
return None
|
||||
return normalized
|
||||
|
||||
@@ -194,6 +194,63 @@ def test_create_harness_agent_returns_full_agent() -> None:
|
||||
assert isinstance(agent, FullAgent)
|
||||
|
||||
|
||||
def test_create_harness_agent_no_token_params_disables_compaction() -> None:
|
||||
"""When token params are omitted, compaction is automatically disabled."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_no_token_params_skips_max_tokens_option() -> None:
|
||||
"""When max_output_tokens is omitted, max_tokens should not be set in default options."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
)
|
||||
assert agent.default_options.get("max_tokens") is None
|
||||
|
||||
|
||||
def test_create_harness_agent_custom_before_strategy_enables_compaction_without_tokens() -> None:
|
||||
"""A custom before_compaction_strategy enables compaction even when token params are omitted."""
|
||||
from agent_framework import ToolResultCompactionStrategy
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
before_compaction_strategy=ToolResultCompactionStrategy(),
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_compaction_overrides_custom_before_strategy() -> None:
|
||||
"""disable_compaction=True wins even when a custom before strategy is provided."""
|
||||
from agent_framework import ToolResultCompactionStrategy
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
before_compaction_strategy=ToolResultCompactionStrategy(),
|
||||
disable_compaction=True,
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_custom_after_strategy_enables_compaction_without_tokens() -> None:
|
||||
"""A custom after_compaction_strategy enables compaction even when token params are omitted."""
|
||||
from agent_framework import ToolResultCompactionStrategy
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
after_compaction_strategy=ToolResultCompactionStrategy(),
|
||||
)
|
||||
compaction_providers = [p for p in agent.context_providers if isinstance(p, CompactionProvider)]
|
||||
assert len(compaction_providers) == 1
|
||||
# Before phase is skipped (no token budget, no custom before strategy), after phase is set.
|
||||
assert compaction_providers[0].before_strategy is None
|
||||
assert compaction_providers[0].after_strategy is not None
|
||||
|
||||
|
||||
# --- Validation Tests ---
|
||||
|
||||
|
||||
@@ -207,14 +264,15 @@ def test_create_harness_agent_rejects_invalid_context_tokens() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_negative_output_tokens() -> None:
|
||||
"""max_output_tokens must be non-negative."""
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be non-negative"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=-1,
|
||||
)
|
||||
def test_create_harness_agent_rejects_non_positive_output_tokens() -> None:
|
||||
"""max_output_tokens must be positive when provided."""
|
||||
for invalid_value in (0, -1):
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be positive"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=invalid_value,
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_output_gte_context() -> None:
|
||||
|
||||
@@ -342,6 +342,69 @@ def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown():
|
||||
assert result[1].text == "Embedded result"
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_only():
|
||||
"""Test that structuredContent is parsed when content list is empty."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[],
|
||||
structuredContent={"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]},
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
parsed = json.loads(result[0].text)
|
||||
assert parsed == {"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]}
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_with_text():
|
||||
"""Test that structuredContent is appended alongside regular content items."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Summary")],
|
||||
structuredContent={"data": [1, 2, 3]},
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "Summary"
|
||||
assert result[1].type == "text"
|
||||
parsed = json.loads(result[1].text)
|
||||
assert parsed == {"data": [1, 2, 3]}
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_none():
|
||||
"""Test that None structuredContent does not affect results."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Hello")],
|
||||
structuredContent=None,
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "Hello"
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_non_serializable():
|
||||
"""Test that non-JSON-serializable values in structuredContent degrade gracefully."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[],
|
||||
structuredContent={"data": b"raw bytes", "count": 42},
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
parsed = json.loads(result[0].text)
|
||||
assert parsed["count"] == 42
|
||||
# bytes should be converted to string representation via default=str
|
||||
assert "raw bytes" in parsed["data"]
|
||||
|
||||
|
||||
def test_mcp_content_types_to_ai_content_text():
|
||||
"""Test conversion of MCP text content to AI content."""
|
||||
mcp_content = types.TextContent(type="text", text="Sample text")
|
||||
@@ -1813,6 +1876,18 @@ async def test_mcp_tool_message_handler_cancel_and_replace():
|
||||
assert len(tool._pending_reload_tasks) == 0
|
||||
|
||||
|
||||
def _approve(_params: object) -> bool:
|
||||
"""Approving sampling gate used by tests that exercise forwarding behavior."""
|
||||
return True
|
||||
|
||||
|
||||
def _make_sampling_response(text: str = "response", model: str = "test-model") -> Mock:
|
||||
mock_response = Mock()
|
||||
mock_response.messages = [Message(role="assistant", contents=[Content.from_text(text)])]
|
||||
mock_response.model = model
|
||||
return mock_response
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_no_client():
|
||||
"""Test sampling callback error path when no chat client is available."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
@@ -1828,9 +1903,190 @@ async def test_mcp_tool_sampling_callback_no_client():
|
||||
assert "No chat client available" in result.message
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_denies_by_default():
|
||||
"""Sampling is denied when no approval callback is configured (safe default)."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
mock_chat_client = AsyncMock()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = []
|
||||
params.maxTokens = 128
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.code == types.INVALID_REQUEST
|
||||
assert "denied" in result.message
|
||||
assert "sampling_approval_callback" in result.message
|
||||
mock_chat_client.get_response.assert_not_called()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_denied_by_callback():
|
||||
"""Sampling is denied when the approval callback returns a falsy value."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=lambda params: False)
|
||||
mock_chat_client = AsyncMock()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = []
|
||||
params.maxTokens = 128
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.code == types.INVALID_REQUEST
|
||||
assert "denied by the 'sampling_approval_callback'" in result.message
|
||||
mock_chat_client.get_response.assert_not_called()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_callback_exception_denies():
|
||||
"""An approval callback that raises results in denial, not an LLM call."""
|
||||
|
||||
def boom(_params: object) -> bool:
|
||||
raise RuntimeError("approval error")
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=boom)
|
||||
mock_chat_client = AsyncMock()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = []
|
||||
params.maxTokens = 128
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.code == types.INVALID_REQUEST
|
||||
mock_chat_client.get_response.assert_not_called()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_async_approval():
|
||||
"""An async approval callback that approves allows the request through."""
|
||||
|
||||
async def approve(_params: object) -> bool:
|
||||
return True
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=approve)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response("ok")
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.CreateMessageResult)
|
||||
assert result.content.text == "ok"
|
||||
mock_chat_client.get_response.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_clamps_max_tokens():
|
||||
"""An approved request's maxTokens is clamped to sampling_max_tokens."""
|
||||
tool = MCPStdioTool(
|
||||
name="test_tool",
|
||||
command="python",
|
||||
sampling_approval_callback=_approve,
|
||||
sampling_max_tokens=512,
|
||||
)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 1_000_000
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.CreateMessageResult)
|
||||
options = mock_chat_client.get_response.call_args.kwargs.get("options") or {}
|
||||
assert options["max_tokens"] == 512
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_does_not_clamp_under_cap():
|
||||
"""A request below the cap keeps its requested maxTokens."""
|
||||
tool = MCPStdioTool(
|
||||
name="test_tool",
|
||||
command="python",
|
||||
sampling_approval_callback=_approve,
|
||||
sampling_max_tokens=512,
|
||||
)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.CreateMessageResult)
|
||||
options = mock_chat_client.get_response.call_args.kwargs.get("options") or {}
|
||||
assert options["max_tokens"] == 100
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_rate_limited():
|
||||
"""Sampling requests beyond sampling_max_requests are rejected per session."""
|
||||
tool = MCPStdioTool(
|
||||
name="test_tool",
|
||||
command="python",
|
||||
sampling_approval_callback=_approve,
|
||||
sampling_max_requests=2,
|
||||
)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
def make_params() -> Mock:
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
return params
|
||||
|
||||
first = await tool.sampling_callback(Mock(), make_params())
|
||||
second = await tool.sampling_callback(Mock(), make_params())
|
||||
third = await tool.sampling_callback(Mock(), make_params())
|
||||
|
||||
assert isinstance(first, types.CreateMessageResult)
|
||||
assert isinstance(second, types.CreateMessageResult)
|
||||
assert isinstance(third, types.ErrorData)
|
||||
assert third.code == types.INVALID_REQUEST
|
||||
assert "rate limit" in third.message.lower()
|
||||
assert mock_chat_client.get_response.await_count == 2
|
||||
|
||||
# The counter resets on a session reset.
|
||||
tool._reset_session_state()
|
||||
fourth = await tool.sampling_callback(Mock(), make_params())
|
||||
assert isinstance(fourth, types.CreateMessageResult)
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_chat_client_exception():
|
||||
"""Test sampling callback when chat client raises exception."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
# Mock chat client that raises exception
|
||||
mock_chat_client = AsyncMock()
|
||||
@@ -1846,7 +2102,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -1863,7 +2119,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
"""Test sampling callback when response has no valid content types."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
# Mock chat client with response containing only invalid content types
|
||||
mock_chat_client = AsyncMock()
|
||||
@@ -1892,7 +2148,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -1905,18 +2161,18 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
assert "Failed to get right content types from the response." in result.message
|
||||
mock_chat_client.get_response.assert_awaited_once()
|
||||
_, kwargs = mock_chat_client.get_response.await_args
|
||||
assert kwargs["options"] == {"max_tokens": None}
|
||||
assert kwargs["options"] == {"max_tokens": 100}
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation():
|
||||
"""Test sampling callback when the chat client returns no response and then valid content."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
tool.client = AsyncMock()
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -1955,7 +2211,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt():
|
||||
"""Test sampling callback passes systemPrompt as instructions in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -1972,7 +2228,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = "You are a helpful assistant"
|
||||
params.tools = None
|
||||
@@ -1990,7 +2246,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools():
|
||||
"""Test sampling callback converts MCP tools to FunctionTools and passes them in options."""
|
||||
from agent_framework import FunctionTool, Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2013,7 +2269,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = [mcp_tool]
|
||||
@@ -2036,7 +2292,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice():
|
||||
"""Test sampling callback passes toolChoice mode in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2053,7 +2309,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -2071,7 +2327,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
|
||||
"""Test sampling callback forwards empty string systemPrompt as instructions."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2088,7 +2344,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = ""
|
||||
params.tools = None
|
||||
@@ -2106,7 +2362,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
|
||||
"""Test sampling callback forwards empty tools list in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2123,7 +2379,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = []
|
||||
@@ -2141,7 +2397,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options(
|
||||
"""Test sampling callback passes temperature, max_tokens, and stop in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2182,7 +2438,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none():
|
||||
"""Test sampling callback does not set temperature in options when it is None."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2219,7 +2475,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens():
|
||||
"""Test sampling callback always sets max_tokens in options since maxTokens is a required int field."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
|
||||
@@ -76,6 +76,7 @@ def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock
|
||||
result = Mock()
|
||||
result.isError = is_error
|
||||
result.content = [types.TextContent(type="text", text=text)]
|
||||
result.structuredContent = None
|
||||
return result
|
||||
|
||||
|
||||
@@ -281,9 +282,7 @@ async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanEx
|
||||
async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
|
||||
"""When session.get_prompt() raises McpError, the span should have error.type and ERROR status."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.get_prompt = AsyncMock(
|
||||
side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))
|
||||
)
|
||||
tool.session.get_prompt = AsyncMock(side_effect=McpError(ErrorData(code=-32602, message="prompt not found")))
|
||||
|
||||
span_exporter.clear()
|
||||
with pytest.raises(ToolExecutionException):
|
||||
|
||||
@@ -35,26 +35,22 @@ description: Convert between common units.
|
||||
Body content here.
|
||||
"""
|
||||
|
||||
SAMPLE_SKILL_INDEX = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
SAMPLE_SKILL_INDEX = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md",
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with a single TextResourceContents."""
|
||||
return ReadResourceResult(
|
||||
contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")]
|
||||
)
|
||||
return ReadResourceResult(contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")])
|
||||
|
||||
|
||||
def _make_blob_result(
|
||||
@@ -230,12 +226,10 @@ class TestMCPSkill:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_text(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
@@ -249,12 +243,10 @@ class TestMCPSkill:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_binary(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
@@ -345,12 +337,10 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_based_discovery_returns_skill(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
@@ -373,9 +363,7 @@ class TestMCPSkillsSource:
|
||||
async def test_does_not_read_skill_md_during_discovery(self) -> None:
|
||||
# Index points to a skill, but SKILL.md is not registered on the server.
|
||||
# Discovery should succeed because it only reads the index.
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
@@ -384,19 +372,17 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_name_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter", # Invalid: uppercase
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter", # Invalid: uppercase
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md",
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -404,18 +390,16 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_fields_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
# Missing description and url
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
# Missing description and url
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -423,19 +407,17 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz",
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -443,18 +425,16 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md",
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -462,31 +442,25 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_index_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_index_json_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_text_resource(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("references/checklist.md")
|
||||
@@ -497,13 +471,11 @@ class TestMCPSkillsSource:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_binary_resource(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("assets/icon.bin")
|
||||
@@ -649,9 +621,7 @@ class TestMCPSkillsSourceErrorCodeBranching:
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=0, message="Handler error"))
|
||||
)
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=0, message="Handler error")))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(McpError):
|
||||
|
||||
+121
-60
@@ -63,6 +63,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
# Allowed identifier shape for object-attribute steps in declarative state paths
|
||||
_SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeclarativeEnvConfig:
|
||||
@@ -266,6 +269,9 @@ class DeclarativeWorkflowState:
|
||||
- Conversation: Conversation history
|
||||
"""
|
||||
|
||||
# Sentinel marking "no prior value" for temporary-key bookkeeping.
|
||||
_MISSING: Any = object()
|
||||
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
|
||||
"""Initialize with a State instance.
|
||||
|
||||
@@ -331,16 +337,21 @@ class DeclarativeWorkflowState:
|
||||
def get(self, path: str, default: Any = None) -> Any:
|
||||
"""Get a value from the state using a dot-notated path.
|
||||
|
||||
Dict-keyed segments may use arbitrary string keys (e.g. UUIDs in
|
||||
``System.conversations.<id>.messages``). Segments that would resolve
|
||||
via object-attribute access must be valid declarative identifiers
|
||||
(``[A-Za-z][A-Za-z0-9_]*``); other shapes return ``default``.
|
||||
|
||||
Args:
|
||||
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
|
||||
default: Default value if path doesn't exist
|
||||
|
||||
Returns:
|
||||
The value at the path, or default if not found
|
||||
The value at the path, or default if not found or unreachable.
|
||||
"""
|
||||
state_data = self.get_state_data()
|
||||
parts = path.split(".")
|
||||
if not parts:
|
||||
if not parts or any(not p for p in parts):
|
||||
return default
|
||||
|
||||
namespace = parts[0]
|
||||
@@ -377,10 +388,19 @@ class DeclarativeWorkflowState:
|
||||
obj = obj.get(part, default) # type: ignore[union-attr]
|
||||
if obj is default:
|
||||
return default
|
||||
elif hasattr(obj, part): # type: ignore[arg-type]
|
||||
obj = getattr(obj, part) # type: ignore[arg-type]
|
||||
else:
|
||||
return default
|
||||
# Attribute access is only allowed for safe declarative identifiers.
|
||||
if not _SAFE_PATH_SEGMENT_RE.match(part):
|
||||
logger.warning(
|
||||
"DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
|
||||
part,
|
||||
path,
|
||||
)
|
||||
return default
|
||||
if hasattr(obj, part): # type: ignore[arg-type]
|
||||
obj = getattr(obj, part) # type: ignore[arg-type]
|
||||
else:
|
||||
return default
|
||||
|
||||
return obj # type: ignore[return-value]
|
||||
|
||||
@@ -392,12 +412,14 @@ class DeclarativeWorkflowState:
|
||||
value: The value to set
|
||||
|
||||
Raises:
|
||||
ValueError: If attempting to set Workflow.Inputs (which is read-only)
|
||||
ValueError: If ``path`` is empty or contains empty segments
|
||||
(e.g. ``"Local."``, ``"Local..foo"``), or if attempting to set
|
||||
``Workflow.Inputs`` (which is read-only).
|
||||
"""
|
||||
state_data = self.get_state_data()
|
||||
parts = path.split(".")
|
||||
if not parts:
|
||||
return
|
||||
if not parts or any(not p for p in parts):
|
||||
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
|
||||
|
||||
namespace = parts[0]
|
||||
remaining = parts[1:]
|
||||
@@ -453,7 +475,16 @@ class DeclarativeWorkflowState:
|
||||
Args:
|
||||
path: Dot-notated path to a list
|
||||
value: The value to append
|
||||
|
||||
Raises:
|
||||
ValueError: If ``path`` is empty or contains empty segments
|
||||
(e.g. ``"Local."``, ``"Local..foo"``), or if the existing
|
||||
value at ``path`` is not a list.
|
||||
"""
|
||||
parts = path.split(".")
|
||||
if not parts or any(not p for p in parts):
|
||||
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
|
||||
|
||||
existing = self.get(path)
|
||||
if existing is None:
|
||||
self.set(path, [value])
|
||||
@@ -464,6 +495,15 @@ class DeclarativeWorkflowState:
|
||||
else:
|
||||
raise ValueError(f"Cannot append to non-list at path '{path}'")
|
||||
|
||||
def _clear_local_path(self, name: str) -> None:
|
||||
"""Remove ``name`` from the ``Local`` namespace, if present."""
|
||||
state_data = self.get_state_data()
|
||||
local = state_data.get("Local")
|
||||
if local is None or name not in local:
|
||||
return
|
||||
local.pop(name, None)
|
||||
self.set_state_data(state_data)
|
||||
|
||||
def eval(self, expression: str) -> Any:
|
||||
"""Evaluate a PowerFx expression with the current state.
|
||||
|
||||
@@ -504,53 +544,64 @@ class DeclarativeWorkflowState:
|
||||
return result
|
||||
|
||||
# Pre-process nested custom functions (e.g., Upper(MessageText(...)))
|
||||
# Replace them with their evaluated results before sending to PowerFx
|
||||
formula = self._preprocess_custom_functions(formula)
|
||||
# and run PowerFx. The finally below restores any temporary state
|
||||
# written during preprocessing, regardless of where execution exits.
|
||||
temp_writes: list[tuple[str, Any]] = []
|
||||
|
||||
if Engine is None:
|
||||
raise RuntimeError(
|
||||
f"PowerFx is not available (dotnet runtime not installed). "
|
||||
f"Expression '={formula[:80]}' cannot be evaluated. "
|
||||
f"Install dotnet and the powerfx package for full PowerFx support."
|
||||
)
|
||||
|
||||
symbols = self._to_powerfx_symbols()
|
||||
# Use setlocale(category) query form so we can restore the exact prior value.
|
||||
# getlocale() returns a normalized tuple and is not always a lossless
|
||||
# round-trip for setlocale across platforms/locales.
|
||||
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
|
||||
try:
|
||||
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
|
||||
try:
|
||||
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
|
||||
break
|
||||
except locale.Error:
|
||||
continue
|
||||
formula = self._preprocess_custom_functions(formula, temp_writes)
|
||||
|
||||
engine = Engine()
|
||||
try:
|
||||
from System.Globalization import ( # pyright: ignore[reportMissingImports]
|
||||
CultureInfo, # pyright: ignore[reportUnknownVariableType]
|
||||
if Engine is None:
|
||||
raise RuntimeError(
|
||||
f"PowerFx is not available (dotnet runtime not installed). "
|
||||
f"Expression '={formula[:80]}' cannot be evaluated. "
|
||||
f"Install dotnet and the powerfx package for full PowerFx support."
|
||||
)
|
||||
except ImportError:
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
|
||||
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
|
||||
symbols = self._to_powerfx_symbols()
|
||||
# Use setlocale(category) query form so we can restore the exact prior value.
|
||||
# getlocale() returns a normalized tuple and is not always a lossless
|
||||
# round-trip for setlocale across platforms/locales.
|
||||
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
|
||||
try:
|
||||
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
|
||||
try:
|
||||
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
|
||||
break
|
||||
except locale.Error:
|
||||
continue
|
||||
|
||||
engine = Engine()
|
||||
try:
|
||||
from System.Globalization import ( # pyright: ignore[reportMissingImports]
|
||||
CultureInfo, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
except ImportError:
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
|
||||
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
|
||||
try:
|
||||
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
finally:
|
||||
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
# Handle undefined variable errors gracefully by returning None
|
||||
# This matches the behavior of the legacy fallback parser
|
||||
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
|
||||
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
|
||||
return None
|
||||
raise
|
||||
finally:
|
||||
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
# Handle undefined variable errors gracefully by returning None
|
||||
# This matches the behavior of the legacy fallback parser
|
||||
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
|
||||
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
|
||||
return None
|
||||
raise
|
||||
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
|
||||
finally:
|
||||
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
|
||||
# Restore each temporary key to its prior value (or remove it).
|
||||
for path, previous in reversed(temp_writes):
|
||||
if previous is self._MISSING:
|
||||
self._clear_local_path(path.removeprefix("Local."))
|
||||
else:
|
||||
self.set(path, previous)
|
||||
|
||||
def _eval_custom_function(self, formula: str) -> Any | None:
|
||||
"""Handle custom functions not supported by the Python PowerFx library.
|
||||
@@ -609,7 +660,7 @@ class DeclarativeWorkflowState:
|
||||
|
||||
return None
|
||||
|
||||
def _preprocess_custom_functions(self, formula: str) -> str:
|
||||
def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str:
|
||||
"""Pre-process custom functions nested inside other PowerFx functions.
|
||||
|
||||
Custom functions like MessageText() are not supported by the PowerFx engine.
|
||||
@@ -624,9 +675,14 @@ class DeclarativeWorkflowState:
|
||||
|
||||
Args:
|
||||
formula: The PowerFx formula to pre-process
|
||||
temp_writes: Caller-owned list. Each write to a temporary key
|
||||
appends a ``(path, previous_value)`` entry where
|
||||
``previous_value`` is the value at ``path`` before the write
|
||||
or :attr:`_MISSING` if none. The caller must restore every
|
||||
entry, including when this method raises mid-write.
|
||||
|
||||
Returns:
|
||||
The formula with custom function calls replaced by their evaluated results
|
||||
The rewritten formula.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -635,7 +691,6 @@ class DeclarativeWorkflowState:
|
||||
# We use 500 to leave room for the rest of the expression around the replaced value.
|
||||
MAX_INLINE_LENGTH = 500
|
||||
|
||||
# Counter for generating unique temp variable names
|
||||
temp_var_counter = 0
|
||||
|
||||
# Custom functions that need pre-processing: (regex pattern, handler)
|
||||
@@ -691,11 +746,14 @@ class DeclarativeWorkflowState:
|
||||
# Replace in formula
|
||||
if isinstance(replacement, str):
|
||||
if len(replacement) > MAX_INLINE_LENGTH:
|
||||
# Store long strings in a temp variable to avoid PowerFx expression limit
|
||||
# Store long results in an underscore-prefixed temp key;
|
||||
# record the prior value so eval() can restore it.
|
||||
temp_var_name = f"_TempMessageText{temp_var_counter}"
|
||||
temp_var_counter += 1
|
||||
self.set(f"Local.{temp_var_name}", replacement)
|
||||
replacement_str = f"Local.{temp_var_name}"
|
||||
temp_var_path = f"Local.{temp_var_name}"
|
||||
temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
|
||||
self.set(temp_var_path, replacement)
|
||||
replacement_str = temp_var_path
|
||||
logger.debug(
|
||||
f"Stored long MessageText result ({len(replacement)} chars) "
|
||||
f"in temp variable {temp_var_name}"
|
||||
@@ -847,11 +905,13 @@ class DeclarativeWorkflowState:
|
||||
return value
|
||||
|
||||
def interpolate_string(self, text: str) -> str:
|
||||
"""Interpolate {Variable.Path} references in a string.
|
||||
"""Interpolate ``{Variable.Path}`` references in a string.
|
||||
|
||||
This handles template-style variable substitution like:
|
||||
- "Created ticket #{Local.TicketParameters.TicketId}"
|
||||
- "Routing to {Local.RoutingParameters.TeamName}"
|
||||
Captures brace-delimited tokens whose root segment is an identifier
|
||||
(``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
|
||||
dict-key segments. Resolution is delegated to :meth:`get`; unresolved
|
||||
tokens are replaced with the empty string. Tokens that do not look
|
||||
like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
|
||||
|
||||
Args:
|
||||
text: Text that may contain {Variable.Path} references
|
||||
@@ -866,10 +926,11 @@ class DeclarativeWorkflowState:
|
||||
value = self.get(var_path)
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
# Match {Variable.Path} patterns
|
||||
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
|
||||
# Root segment must be an identifier; follow-on segments accept any
|
||||
# non-empty dict-key (e.g. ``_id``, ``1``, UUIDs). ``get()`` enforces
|
||||
# per-segment safety on attribute traversal.
|
||||
pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
|
||||
|
||||
# Replace all matches
|
||||
result = text
|
||||
for match in re.finditer(pattern, text):
|
||||
replacement = replace_var(match)
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
|
||||
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
|
||||
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
"""Path-segment validation tests for DeclarativeWorkflowState.
|
||||
|
||||
Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
|
||||
placeholders in ``interpolate_string`` are subject to three distinct rules
|
||||
that this module pins:
|
||||
|
||||
- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
|
||||
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
|
||||
``interpolate_string`` return their default / leave the placeholder literal;
|
||||
``set`` and ``append`` raise ``ValueError``.
|
||||
- **Object-attribute segments** — segments that ``get`` would resolve via
|
||||
``getattr`` because the parent is a non-dict object — must match the safe
|
||||
identifier shape ``[A-Za-z][A-Za-z0-9_]*``. Other shapes are rejected with a
|
||||
warning log and the default is returned.
|
||||
- **Dict-keyed segments** — segments that resolve via dict lookup because the
|
||||
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
|
||||
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_declarative._workflows import DeclarativeWorkflowState
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state() -> MagicMock:
|
||||
"""In-memory mock for the underlying State."""
|
||||
ms = MagicMock()
|
||||
ms._data = {}
|
||||
|
||||
def get(key: str, default: Any = None) -> Any:
|
||||
return ms._data.get(key, default)
|
||||
|
||||
def set_(key: str, value: Any) -> None:
|
||||
ms._data[key] = value
|
||||
|
||||
def has(key: str) -> bool:
|
||||
return key in ms._data
|
||||
|
||||
def delete(key: str) -> None:
|
||||
ms._data.pop(key, None)
|
||||
|
||||
ms.get = MagicMock(side_effect=get)
|
||||
ms.set = MagicMock(side_effect=set_)
|
||||
ms.has = MagicMock(side_effect=has)
|
||||
ms.delete = MagicMock(side_effect=delete)
|
||||
return ms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(mock_state: MagicMock) -> DeclarativeWorkflowState:
|
||||
s = DeclarativeWorkflowState(mock_state)
|
||||
s.initialize()
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PlainObj:
|
||||
"""Non-dict object so ``get`` falls through to attribute access."""
|
||||
|
||||
text: str = "hi"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get(): invalid paths return default
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetRejectsInvalidPaths:
|
||||
def test_rejects_dunder_segment_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
assert state.get("Local.obj.__class__") is None
|
||||
assert state.get("Local.obj.__class__", default="DEF") == "DEF"
|
||||
|
||||
def test_rejects_full_env_exfil_chain(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
|
||||
sentinel = "agent-framework-path-safety-sentinel"
|
||||
monkeypatch.setenv("AF_PATH_SAFETY_SENTINEL", sentinel)
|
||||
state.set("Local.obj", _PlainObj())
|
||||
|
||||
result = state.get("Local.obj.__class__.__init__.__globals__.os.environ")
|
||||
|
||||
assert result is None
|
||||
assert sentinel not in str(result)
|
||||
|
||||
def test_rejects_leading_underscore_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
assert state.get("Local.obj._private") is None
|
||||
|
||||
def test_rejects_invalid_chars_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
assert state.get("Local.obj.text bar") is None
|
||||
assert state.get("Local.obj.text-bar") is None
|
||||
|
||||
def test_rejects_empty_path_and_empty_segments(self, state: DeclarativeWorkflowState) -> None:
|
||||
assert state.get("") is None
|
||||
assert state.get(".") is None
|
||||
assert state.get("Local.") is None
|
||||
assert state.get(".Local") is None
|
||||
|
||||
def test_warning_logged_on_rejected_attribute_segment(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework_declarative._workflows._declarative_base"):
|
||||
state.get("Local.obj.__class__")
|
||||
assert any("rejecting attribute segment" in r.message for r in caplog.records)
|
||||
|
||||
def test_dict_keyed_dunder_is_not_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""A literal dunder dict key is harmless because dict lookup never reaches getattr."""
|
||||
state.set("Local.bag", {"__class__": "harmless-string"})
|
||||
assert state.get("Local.bag.__class__") == "harmless-string"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get(): legitimate paths continue to work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAllowsValidPaths:
|
||||
def test_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.user_input", "ok")
|
||||
assert state.get("Local.user_input") == "ok"
|
||||
|
||||
def test_mixed_case_identifiers(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.UserInput", "u1")
|
||||
state.set("Local.userInput", "u2")
|
||||
assert state.get("Local.UserInput") == "u1"
|
||||
assert state.get("Local.userInput") == "u2"
|
||||
|
||||
def test_object_attribute_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.msg", _PlainObj(text="hello"))
|
||||
assert state.get("Local.msg.text") == "hello"
|
||||
|
||||
def test_nested_dict_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.params", {"team": {"name": "alpha"}})
|
||||
assert state.get("Local.params.team.name") == "alpha"
|
||||
|
||||
def test_uuid_and_hyphenated_dict_keys_are_allowed(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Conversation-id style paths use arbitrary dict keys (UUIDs / hyphens)."""
|
||||
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
|
||||
state.set(f"System.conversations.{conv_id}.messages", ["m1", "m2"])
|
||||
assert state.get(f"System.conversations.{conv_id}.messages") == ["m1", "m2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set() / append(): dict-keyed operations accept arbitrary string keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetAndAppend:
|
||||
def test_set_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.user_input", "ok")
|
||||
assert state.get("Local.user_input") == "ok"
|
||||
|
||||
def test_set_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
|
||||
conv_id = "conv-test-1"
|
||||
state.set(f"System.conversations.{conv_id}.messages", [])
|
||||
assert state.get(f"System.conversations.{conv_id}.messages") == []
|
||||
|
||||
def test_append_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
|
||||
conv_id = "conv-42"
|
||||
state.append(f"System.conversations.{conv_id}.messages", {"role": "user", "text": "hi"})
|
||||
msgs = state.get(f"System.conversations.{conv_id}.messages")
|
||||
assert msgs == [{"role": "user", "text": "hi"}]
|
||||
|
||||
def test_workflow_inputs_still_read_only(self, state: DeclarativeWorkflowState) -> None:
|
||||
with pytest.raises(ValueError, match="read-only"):
|
||||
state.set("Workflow.Inputs.x", 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set() / append(): malformed paths (empty segments) raise ValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetRejectsInvalidPaths:
|
||||
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
|
||||
def test_set_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
|
||||
with pytest.raises(ValueError, match="empty segments are not allowed"):
|
||||
state.set(bad_path, "x")
|
||||
|
||||
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
|
||||
def test_append_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
|
||||
with pytest.raises(ValueError, match="empty segments are not allowed"):
|
||||
state.append(bad_path, "x")
|
||||
|
||||
def test_set_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Rejected set() must not create an unreachable entry in the state."""
|
||||
state.set("Local.user_input", "pre")
|
||||
with pytest.raises(ValueError):
|
||||
state.set("Local.", "value")
|
||||
local = state.get_state_data().get("Local", {})
|
||||
assert "" not in local
|
||||
assert local == {"user_input": "pre"}
|
||||
assert state.get("Local.") is None
|
||||
assert state.get("Local.user_input") == "pre"
|
||||
|
||||
def test_append_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Rejected append() must not create an unreachable entry in the state."""
|
||||
state.set("Local.items", ["a"])
|
||||
with pytest.raises(ValueError):
|
||||
state.append("Local.", "value")
|
||||
local = state.get_state_data().get("Local", {})
|
||||
assert "" not in local
|
||||
assert local == {"items": ["a"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# interpolate_string(): permissive matcher; get() enforces safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInterpolateString:
|
||||
def test_ignores_dunder_payload(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
|
||||
sentinel = "agent-framework-interp-sentinel"
|
||||
monkeypatch.setenv("AF_INTERP_SENTINEL", sentinel)
|
||||
state.set("Local.obj", _PlainObj())
|
||||
|
||||
out = state.interpolate_string("X={Local.obj.__class__.__init__.__globals__.os.environ}")
|
||||
|
||||
assert sentinel not in out
|
||||
assert out == "X="
|
||||
|
||||
def test_unknown_path_reduces_to_empty(self, state: DeclarativeWorkflowState) -> None:
|
||||
assert state.interpolate_string("v={Local._private}") == "v="
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"literal",
|
||||
["{foo-bar}", "{Ctrl+C}", "{not:a:path}", "{Local.}", "{}"],
|
||||
)
|
||||
def test_non_state_braced_tokens_left_literal(self, state: DeclarativeWorkflowState, literal: str) -> None:
|
||||
assert state.interpolate_string(f"v={literal}") == f"v={literal}"
|
||||
|
||||
def test_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.user_input", "hello")
|
||||
assert state.interpolate_string("v={Local.user_input}") == "v=hello"
|
||||
|
||||
def test_resolves_nested_dict_path(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.params", {"team": "alpha"})
|
||||
assert state.interpolate_string("team={Local.params.team}") == "team=alpha"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("_id", "abc123"),
|
||||
("1", "one"),
|
||||
("2025", "year-bucket"),
|
||||
],
|
||||
)
|
||||
def test_resolves_dict_keyed_segments(self, state: DeclarativeWorkflowState, key: str, value: str) -> None:
|
||||
state.set("Local.bag", {key: value})
|
||||
assert state.interpolate_string(f"v={{Local.bag.{key}}}") == f"v={value}"
|
||||
|
||||
def test_resolves_uuid_conversation_key(self, state: DeclarativeWorkflowState) -> None:
|
||||
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
|
||||
state.set(f"System.conversations.{conv_id}.messages", ["hello"])
|
||||
out = state.interpolate_string(f"m={{System.conversations.{conv_id}.messages}}")
|
||||
assert out == "m=['hello']"
|
||||
|
||||
def test_end_to_end_send_activity_payload_neutralized(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
sentinel = "agent-framework-e2e-sentinel"
|
||||
monkeypatch.setenv("AF_E2E_SENTINEL", sentinel)
|
||||
state.set("Local.toolResult", _PlainObj())
|
||||
|
||||
payload = "{Local.toolResult.__class__.__init__.__globals__.os.environ}"
|
||||
evaluated = state.eval_if_expression(payload)
|
||||
rendered = state.interpolate_string(evaluated) if isinstance(evaluated, str) else str(evaluated)
|
||||
|
||||
assert sentinel not in rendered
|
||||
assert rendered == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regressions: PowerFx and internal temp-variable handling still work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestPowerFxStillWorks:
|
||||
def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.x", 6)
|
||||
state.set("Local.y", 7)
|
||||
assert state.eval("=Local.x * Local.y") == 42
|
||||
|
||||
def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Long MessageText() results round-trip and the temp key is removed after eval."""
|
||||
long_text = "A" * 600
|
||||
state.set(
|
||||
"Local.Messages",
|
||||
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
|
||||
)
|
||||
|
||||
result = state.eval("=Upper(MessageText(Local.Messages))")
|
||||
assert result == "A" * 600
|
||||
|
||||
local = state.get_state_data().get("Local", {})
|
||||
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
|
||||
assert not remaining, f"Temporary keys remain in Local: {remaining}"
|
||||
|
||||
def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""User state at the temp key path survives a long MessageText eval."""
|
||||
long_text = "A" * 600
|
||||
state.set("Local._TempMessageText0", "user-important-value")
|
||||
state.set(
|
||||
"Local.Messages",
|
||||
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
|
||||
)
|
||||
|
||||
result = state.eval("=Upper(MessageText(Local.Messages))")
|
||||
assert result == "A" * 600
|
||||
assert state.get("Local._TempMessageText0") == "user-important-value"
|
||||
|
||||
def test_message_text_eval_cleans_up_on_powerfx_failure(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Temp key is removed even when PowerFx evaluation raises."""
|
||||
from agent_framework_declarative._workflows import _declarative_base as base
|
||||
|
||||
class _FailingEngine:
|
||||
def eval(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(base, "Engine", _FailingEngine)
|
||||
|
||||
long_text = "A" * 600
|
||||
state.set(
|
||||
"Local.Messages",
|
||||
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
state.eval("=Upper(MessageText(Local.Messages))")
|
||||
|
||||
local = state.get_state_data().get("Local", {})
|
||||
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
|
||||
assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}"
|
||||
@@ -2765,7 +2765,7 @@ class TestLongMessageTextHandling:
|
||||
assert temp_var is None
|
||||
|
||||
async def test_long_message_text_stored_in_temp_variable(self, mock_state):
|
||||
"""Test that long MessageText results are stored in temp variables."""
|
||||
"""Long MessageText results round-trip and the temp key is removed after eval."""
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
@@ -2777,9 +2777,9 @@ class TestLongMessageTextHandling:
|
||||
result = state.eval("=Upper(MessageText(Local.Messages))")
|
||||
assert result == "A" * 600 # Upper on 'A' is still 'A'
|
||||
|
||||
# A temp variable should have been created
|
||||
temp_var = state.get("Local._TempMessageText0")
|
||||
assert temp_var == long_text
|
||||
local = state.get_state_data().get("Local", {})
|
||||
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
|
||||
assert not remaining, f"Temporary keys remain in Local: {remaining}"
|
||||
|
||||
async def test_find_with_long_message_text(self, mock_state):
|
||||
"""Test Find function works with long MessageText stored in temp variable."""
|
||||
|
||||
@@ -198,12 +198,7 @@ class TestBeforeRun:
|
||||
"""OSS client with all scoping parameters passes them as isolated concurrent kwargs."""
|
||||
mock_oss_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_oss_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1"
|
||||
)
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1")
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
|
||||
@@ -320,4 +320,5 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError,
|
||||
- **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses.
|
||||
- **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request.
|
||||
- **Caching**: Protection scopes responses and 402 errors are cached by default with a 4-hour TTL. Cache is automatically invalidated when protection scope state changes.
|
||||
- **Cold-cache parallelization**: On a `ProtectionScopes` cache miss, scopes are refreshed in the background while `ProcessContent` runs in the foreground.
|
||||
- **Background Processing**: Content Activities and offline Process Content requests are handled asynchronously using background tasks to avoid blocking the main execution flow.
|
||||
|
||||
@@ -231,18 +231,19 @@ class ScopedContentProcessor:
|
||||
cached_ps_resp = await self._cache.get(cache_key)
|
||||
|
||||
if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse):
|
||||
ps_resp = cached_ps_resp
|
||||
else:
|
||||
ttl = self._settings.get("cache_ttl_seconds")
|
||||
ttl_seconds = ttl if ttl is not None else 14400
|
||||
try:
|
||||
ps_resp = await self._client.get_protection_scopes(ps_req)
|
||||
await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
|
||||
except PurviewPaymentRequiredError as ex:
|
||||
# Cache the exception at tenant level so all subsequent requests for this tenant fail fast
|
||||
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
|
||||
raise
|
||||
return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key)
|
||||
|
||||
task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return await self._call_process_content(pc_request, cache_key, dlp_actions=[])
|
||||
|
||||
async def _process_with_cached_scopes(
|
||||
self,
|
||||
pc_request: ProcessContentRequest,
|
||||
ps_resp: ProtectionScopesResponse,
|
||||
cache_key: str,
|
||||
) -> ProcessContentResponse:
|
||||
if ps_resp.scope_identifier:
|
||||
pc_request.scope_identifier = ps_resp.scope_identifier
|
||||
|
||||
@@ -259,13 +260,7 @@ class ScopedContentProcessor:
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
|
||||
|
||||
pc_resp = await self._client.process_content(pc_request)
|
||||
|
||||
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
|
||||
await self._cache.remove(cache_key)
|
||||
|
||||
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
|
||||
return pc_resp
|
||||
return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions)
|
||||
|
||||
# No applicable scopes - send content activities in background
|
||||
ca_req = ContentActivitiesRequest(
|
||||
@@ -281,12 +276,52 @@ class ScopedContentProcessor:
|
||||
# Respond with HttpStatusCode 204(No Content)
|
||||
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
|
||||
|
||||
async def _call_process_content(
|
||||
self,
|
||||
pc_request: ProcessContentRequest,
|
||||
cache_key: str,
|
||||
dlp_actions: list[DlpActionInfo],
|
||||
) -> ProcessContentResponse:
|
||||
pc_resp = await self._client.process_content(pc_request)
|
||||
|
||||
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
|
||||
await self._cache.remove(cache_key)
|
||||
|
||||
if dlp_actions:
|
||||
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
|
||||
return pc_resp
|
||||
|
||||
async def _refresh_protection_scopes_background(
|
||||
self, ps_req: ProtectionScopesRequest, cache_key: str, pc_request: ProcessContentRequest
|
||||
) -> None:
|
||||
"""Fetch protection scopes and warm the cache without blocking the foreground call."""
|
||||
ttl = self._settings.get("cache_ttl_seconds")
|
||||
ttl_seconds = ttl if ttl is not None else 14400
|
||||
try:
|
||||
ps_resp = await self._client.get_protection_scopes(ps_req)
|
||||
await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
|
||||
should_process, _, _ = self._check_applicable_scopes(pc_request, ps_resp)
|
||||
if not should_process:
|
||||
ca_req = ContentActivitiesRequest(
|
||||
user_id=pc_request.user_id,
|
||||
tenant_id=pc_request.tenant_id,
|
||||
content_to_process=pc_request.content_to_process,
|
||||
correlation_id=pc_request.correlation_id,
|
||||
)
|
||||
await self._send_content_activities_background(ca_req)
|
||||
except PurviewPaymentRequiredError as ex:
|
||||
tenant_payment_cache_key = f"purview:payment_required:{ps_req.tenant_id}"
|
||||
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
|
||||
logger.warning("Background protection scopes refresh failed with payment required: %s", ex)
|
||||
except Exception as ex:
|
||||
logger.warning("Background protection scopes refresh failed: %s", ex)
|
||||
|
||||
async def _process_content_background(self, pc_request: ProcessContentRequest, cache_key: str) -> None:
|
||||
"""Process content in background for offline execution mode."""
|
||||
try:
|
||||
pc_resp = await self._client.process_content(pc_request)
|
||||
|
||||
# If protection scope state is modified, make another PC request and invalidate cache
|
||||
# If protection scopes changed, invalidate cache and retry once.
|
||||
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
|
||||
await self._cache.remove(cache_key)
|
||||
await self._client.process_content(pc_request)
|
||||
@@ -306,14 +341,10 @@ class ScopedContentProcessor:
|
||||
def _combine_policy_actions(
|
||||
existing: list[DlpActionInfo] | None, new_actions: list[DlpActionInfo]
|
||||
) -> list[DlpActionInfo]:
|
||||
by_key: dict[str, DlpActionInfo] = {}
|
||||
for a in existing or []:
|
||||
if a.action:
|
||||
by_key[a.action] = a
|
||||
for a in new_actions:
|
||||
if a.action:
|
||||
by_key[a.action] = a
|
||||
return list(by_key.values())
|
||||
combined: dict[tuple[DlpAction | None, RestrictionAction | None], DlpActionInfo] = {}
|
||||
for action_info in (existing or []) + new_actions:
|
||||
combined.setdefault((action_info.action, action_info.restriction_action), action_info)
|
||||
return list(combined.values())
|
||||
|
||||
@staticmethod
|
||||
def _check_applicable_scopes(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
"""Tests for Purview processor."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -217,10 +218,38 @@ class TestScopedContentProcessor:
|
||||
assert action1 in combined
|
||||
assert action2 in combined
|
||||
|
||||
async def test_combine_policy_actions_preserves_restriction_only_actions(
|
||||
self, processor: ScopedContentProcessor
|
||||
) -> None:
|
||||
"""Test _combine_policy_actions keeps actions that only set restrictionAction."""
|
||||
existing_action = DlpActionInfo(action=DlpAction.OTHER, restrictionAction=RestrictionAction.OTHER)
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
|
||||
combined = processor._combine_policy_actions([existing_action], [restriction_only_action])
|
||||
|
||||
assert combined == [existing_action, restriction_only_action]
|
||||
|
||||
async def test_combine_policy_actions_deduplicates_by_action_and_restriction(
|
||||
self, processor: ScopedContentProcessor
|
||||
) -> None:
|
||||
"""Test _combine_policy_actions removes exact duplicate actions."""
|
||||
block_action = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK)
|
||||
duplicate_block_action = DlpActionInfo(
|
||||
action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK
|
||||
)
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
|
||||
combined = processor._combine_policy_actions(
|
||||
[block_action],
|
||||
[duplicate_block_action, restriction_only_action],
|
||||
)
|
||||
|
||||
assert combined == [block_action, restriction_only_action]
|
||||
|
||||
async def test_process_with_scopes_calls_client_methods(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test _process_with_scopes calls get_protection_scopes when scopes response is empty."""
|
||||
"""Test _process_with_scopes calls process_content immediately and warms scopes in background on cache miss."""
|
||||
from agent_framework_purview._models import (
|
||||
ContentActivitiesResponse,
|
||||
ProtectionScopesResponse,
|
||||
@@ -236,38 +265,91 @@ class TestScopedContentProcessor:
|
||||
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
# When no scopes apply, process_content is not called (activities are sent in background)
|
||||
mock_client.process_content.assert_not_called()
|
||||
# The response should have id=204 (No Content) when no scopes apply
|
||||
assert response.id == "204"
|
||||
# On cache miss, ProcessContent runs in the foreground and the response is returned.
|
||||
assert response.id == "response-123"
|
||||
mock_client.process_content.assert_called_once()
|
||||
|
||||
async def test_process_with_scopes_ignores_unexpected_cached_value_type(
|
||||
# Protection scopes are refreshed in a background task.
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
|
||||
async def test_process_with_scopes_preserves_restriction_only_policy_actions(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that a corrupted cache entry does not crash processing."""
|
||||
"""Test cold-cache ProcessContent actions are not dropped when they only contain restrictionAction."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
request = process_content_request_factory()
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
|
||||
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(
|
||||
id="response-123",
|
||||
protection_scope_state="notModified",
|
||||
policy_actions=[restriction_only_action],
|
||||
)
|
||||
)
|
||||
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
assert response.policy_actions == [restriction_only_action]
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
async def test_process_with_cached_scopes_preserves_restriction_only_policy_actions(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test cached ProtectionScopes actions are not dropped when they only contain restrictionAction."""
|
||||
from agent_framework_purview._models import (
|
||||
ExecutionMode,
|
||||
PolicyLocation,
|
||||
PolicyScope,
|
||||
ProcessContentResponse,
|
||||
ProtectionScopeActivities,
|
||||
ProtectionScopesResponse,
|
||||
)
|
||||
|
||||
request = process_content_request_factory()
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
process_content_action = DlpActionInfo(action=DlpAction.OTHER, restriction_action=RestrictionAction.OTHER)
|
||||
scope_location = PolicyLocation(
|
||||
data_type="microsoft.graph.policyLocationApplication",
|
||||
value="app-id",
|
||||
)
|
||||
scope = PolicyScope(
|
||||
activities=ProtectionScopeActivities.UPLOAD_TEXT,
|
||||
locations=[scope_location],
|
||||
policy_actions=[restriction_only_action],
|
||||
execution_mode=ExecutionMode.EVALUATE_INLINE,
|
||||
)
|
||||
|
||||
# Return a valid, inline scope so we stay on the normal (non-background) path.
|
||||
scope_location = PolicyLocation(**{
|
||||
"@odata.type": "microsoft.graph.policyLocationApplication",
|
||||
"value": "app-id",
|
||||
})
|
||||
scope = PolicyScope(**{
|
||||
"activities": ProtectionScopeActivities.UPLOAD_TEXT,
|
||||
"locations": [scope_location],
|
||||
"execution_mode": ExecutionMode.EVALUATE_INLINE,
|
||||
})
|
||||
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": [scope]}))
|
||||
processor._cache.get = AsyncMock(
|
||||
side_effect=[
|
||||
None,
|
||||
ProtectionScopesResponse(scope_identifier="scope-123", scopes=[scope]),
|
||||
]
|
||||
) # type: ignore[method-assign]
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(
|
||||
id="response-123",
|
||||
protection_scope_state="notModified",
|
||||
policy_actions=[process_content_action],
|
||||
)
|
||||
)
|
||||
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
assert response.policy_actions == [process_content_action, restriction_only_action]
|
||||
|
||||
async def test_process_with_scopes_ignores_unexpected_cached_value_type(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that a corrupted cache entry does not crash processing."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
request = process_content_request_factory()
|
||||
|
||||
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(**{"id": "ok", "protectionScopeState": "notModified"})
|
||||
)
|
||||
@@ -279,8 +361,9 @@ class TestScopedContentProcessor:
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
assert response.id == "ok"
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
mock_client.process_content.assert_called_once()
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
async def test_process_with_scopes_uses_tenant_payment_exception_cache(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
@@ -301,8 +384,6 @@ class TestScopedContentProcessor:
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test offline background processing invalidates cache and retries when scope state changes."""
|
||||
from agent_framework_purview._models import ProcessContentResponse
|
||||
|
||||
request = process_content_request_factory()
|
||||
request.scope_identifier = "etag-1"
|
||||
|
||||
@@ -319,6 +400,36 @@ class TestScopedContentProcessor:
|
||||
processor._cache.remove.assert_called_once_with("purview:protection_scopes:abc")
|
||||
assert mock_client.process_content.call_count == 2
|
||||
|
||||
async def test_background_scope_refresh_caches_payment_required(
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""402 raised during background scope refresh is cached at the tenant level."""
|
||||
from agent_framework_purview._cache import InMemoryCacheProvider
|
||||
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
|
||||
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
purview_app_location=PurviewAppLocation(
|
||||
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
|
||||
),
|
||||
)
|
||||
|
||||
cache = InMemoryCacheProvider()
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache)
|
||||
|
||||
mock_client.get_protection_scopes = AsyncMock(side_effect=PurviewPaymentRequiredError("nope"))
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(**{"id": "pc-1", "protectionScopeState": "notModified"})
|
||||
)
|
||||
|
||||
request = process_content_request_factory()
|
||||
await processor._process_with_scopes(request)
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
cached = await cache.get(f"purview:payment_required:{request.tenant_id}")
|
||||
assert isinstance(cached, PurviewPaymentRequiredError)
|
||||
|
||||
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
|
||||
"""Test user_id extraction from message additional_properties."""
|
||||
settings = PurviewSettings(
|
||||
@@ -387,6 +498,8 @@ class TestScopedContentProcessor:
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that response is returned when scopes don't apply (activities sent in background)."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
@@ -398,10 +511,8 @@ class TestScopedContentProcessor:
|
||||
|
||||
pc_request = process_content_request_factory()
|
||||
|
||||
# Mock get_protection_scopes to return no applicable scopes
|
||||
mock_ps_response = MagicMock()
|
||||
mock_ps_response.scopes = []
|
||||
mock_client.get_protection_scopes.return_value = mock_ps_response
|
||||
mock_ps_response = ProtectionScopesResponse(scopes=[])
|
||||
processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign]
|
||||
|
||||
# Mock send_content_activities to return success (called in background)
|
||||
mock_ca_response = MagicMock()
|
||||
@@ -410,8 +521,10 @@ class TestScopedContentProcessor:
|
||||
|
||||
response = await processor._process_with_scopes(pc_request)
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
mock_client.get_protection_scopes.assert_not_called()
|
||||
mock_client.process_content.assert_not_called()
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
# Response should have id=204 when no scopes apply
|
||||
assert response.id == "204"
|
||||
|
||||
@@ -419,6 +532,8 @@ class TestScopedContentProcessor:
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that errors in background activities don't affect the response."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
@@ -430,10 +545,8 @@ class TestScopedContentProcessor:
|
||||
|
||||
pc_request = process_content_request_factory()
|
||||
|
||||
# Mock get_protection_scopes to return no applicable scopes
|
||||
mock_ps_response = MagicMock()
|
||||
mock_ps_response.scopes = []
|
||||
mock_client.get_protection_scopes.return_value = mock_ps_response
|
||||
mock_ps_response = ProtectionScopesResponse(scopes=[])
|
||||
processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign]
|
||||
|
||||
# Mock send_content_activities to return error (called in background task)
|
||||
mock_ca_response = MagicMock()
|
||||
@@ -445,6 +558,8 @@ class TestScopedContentProcessor:
|
||||
# Since activities are sent in background, errors don't affect the response
|
||||
# Response should have id=204 when no scopes apply
|
||||
assert response.id == "204"
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
|
||||
|
||||
class TestUserIdResolution:
|
||||
@@ -656,10 +771,12 @@ class TestScopedContentProcessorCaching:
|
||||
mock_client.get_protection_scopes.return_value = ProtectionScopesResponse(
|
||||
scope_identifier="scope-123", scopes=[]
|
||||
)
|
||||
mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified")
|
||||
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
|
||||
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
@@ -670,7 +787,7 @@ class TestScopedContentProcessorCaching:
|
||||
async def test_payment_required_exception_cached_at_tenant_level(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test that 402 payment required exceptions are cached at tenant level."""
|
||||
"""Test that background scope 402 returns once, then throws from the tenant-level cache."""
|
||||
from agent_framework_purview._cache import InMemoryCacheProvider
|
||||
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
|
||||
|
||||
@@ -678,13 +795,12 @@ class TestScopedContentProcessorCaching:
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
|
||||
|
||||
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
|
||||
mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified")
|
||||
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
|
||||
with pytest.raises(PurviewPaymentRequiredError):
|
||||
await processor.process_messages(
|
||||
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
|
||||
)
|
||||
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
|
||||
@@ -45,13 +45,23 @@ python samples/02-agents/harness/harness_research.py
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
`create_harness_agent` requires only a chat client and token budget parameters:
|
||||
`create_harness_agent` requires only a chat client:
|
||||
|
||||
```python
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=FoundryChatClient(credential=AzureCliCredential()),
|
||||
)
|
||||
```
|
||||
|
||||
### With Compaction
|
||||
|
||||
Provide token budget parameters to enable automatic context-window compaction:
|
||||
|
||||
```python
|
||||
agent = create_harness_agent(
|
||||
client=FoundryChatClient(credential=AzureCliCredential()),
|
||||
max_context_window_tokens=128_000,
|
||||
@@ -59,7 +69,7 @@ agent = create_harness_agent(
|
||||
)
|
||||
```
|
||||
|
||||
### Customization
|
||||
### Further Customization
|
||||
|
||||
Disable or customize any feature:
|
||||
|
||||
|
||||
@@ -313,9 +313,7 @@ class HarnessAgentRunner:
|
||||
"""
|
||||
actions: list[FollowUpAction] = []
|
||||
for observer in self._observers:
|
||||
observer_actions = await observer.on_stream_complete(
|
||||
self._ux, self._agent, session
|
||||
)
|
||||
observer_actions = await observer.on_stream_complete(self._ux, self._agent, session)
|
||||
if observer_actions:
|
||||
actions.extend(observer_actions)
|
||||
return actions
|
||||
|
||||
@@ -182,18 +182,12 @@ class HarnessApp(App[None]):
|
||||
if command_handlers is None:
|
||||
from .commands import build_default_command_handlers
|
||||
|
||||
self._command_handlers = build_default_command_handlers(
|
||||
agent, mode_colors=mode_colors
|
||||
)
|
||||
self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors)
|
||||
else:
|
||||
self._command_handlers = command_handlers
|
||||
|
||||
# Compute help text from command handlers
|
||||
help_parts = [
|
||||
h.get_help_text()
|
||||
for h in self._command_handlers
|
||||
if h.get_help_text() is not None
|
||||
]
|
||||
help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None]
|
||||
help_text = ", ".join(help_parts) if help_parts else None
|
||||
|
||||
# State and driver
|
||||
|
||||
@@ -45,9 +45,7 @@ class TodoCommandHandler(CommandHandler):
|
||||
ux.append_info_line("TodoProvider is not available.")
|
||||
return True
|
||||
|
||||
todos = await self._todo_provider.store.load_items(
|
||||
session, source_id=self._todo_provider.source_id
|
||||
)
|
||||
todos = await self._todo_provider.store.load_items(session, source_id=self._todo_provider.source_id)
|
||||
|
||||
if not todos:
|
||||
ux.append_info_line("No todos yet.")
|
||||
|
||||
@@ -72,7 +72,7 @@ class HarnessScrollPanel(RichLog):
|
||||
|
||||
# Truncate lines back to where streaming started
|
||||
if len(self.lines) > self._streaming_line_start:
|
||||
del self.lines[self._streaming_line_start:]
|
||||
del self.lines[self._streaming_line_start :]
|
||||
from textual.geometry import Size
|
||||
|
||||
self.virtual_size = Size(self._widest_line_width, len(self.lines))
|
||||
|
||||
@@ -41,8 +41,7 @@ class PlanningQuestion(BaseModel):
|
||||
choices: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"For clarifications, this has a list of options that the user can "
|
||||
"choose from. null for approvals."
|
||||
"For clarifications, this has a list of options that the user can choose from. null for approvals."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to
|
||||
| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument |
|
||||
| **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication |
|
||||
| **Long-Running Task** | [`mcp_long_running_task.py`](mcp_long_running_task.py) | Demonstrates transparent SEP-2663 long-running task handling for MCP tools that advertise `taskSupport=required`. Self-spawns a stdio MCP child server |
|
||||
| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
from mcp import types
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
MCP Sampling Approval Example
|
||||
|
||||
MCP servers can send the client a ``sampling/createMessage`` request, asking the
|
||||
client to run an LLM completion on the server's behalf. Because remote MCP
|
||||
servers are untrusted third parties, forwarding these server-controlled prompts
|
||||
to your chat client without review is a confused-deputy risk: a malicious server
|
||||
could exfiltrate context, force tool calls, or burn through your token budget.
|
||||
|
||||
For that reason Agent Framework **denies MCP sampling by default**. To allow it,
|
||||
pass a ``sampling_approval_callback`` to the MCP tool. The callback receives the
|
||||
raw ``CreateMessageRequestParams`` and returns ``True`` to approve or ``False``
|
||||
to deny. It may be synchronous or asynchronous, so you can implement a
|
||||
human-in-the-loop prompt, a policy check, or an audit log.
|
||||
|
||||
Two further guardrails apply to approved requests:
|
||||
- ``sampling_max_tokens`` caps the server-requested ``maxTokens``.
|
||||
- ``sampling_max_requests`` limits how many sampling requests a single session
|
||||
may make.
|
||||
|
||||
To restore the legacy "always approve" behavior (only do this for servers you
|
||||
trust), pass ``sampling_approval_callback=lambda params: True``.
|
||||
"""
|
||||
|
||||
|
||||
async def approve_sampling(params: types.CreateMessageRequestParams) -> bool:
|
||||
"""Human-in-the-loop approval gate for server-initiated sampling.
|
||||
|
||||
Shows the server-supplied system prompt and messages, then asks the user to
|
||||
approve or deny. Returning ``False`` rejects the request.
|
||||
"""
|
||||
print("\n--- MCP server requested a sampling/createMessage ---")
|
||||
if params.systemPrompt:
|
||||
print(f"System prompt: {params.systemPrompt}")
|
||||
for message in params.messages:
|
||||
text = getattr(message.content, "text", message.content)
|
||||
print(f"{message.role}: {text}")
|
||||
answer = await asyncio.to_thread(input, "Approve this sampling request? [y/N]: ")
|
||||
return answer.strip().lower() in {"y", "yes"}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run an agent against an MCP server with a sampling approval gate."""
|
||||
async with Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Agent",
|
||||
instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.",
|
||||
tools=MCPStreamableHTTPTool(
|
||||
name="MCP tool",
|
||||
description="MCP tool description.",
|
||||
url="<your mcp server url>",
|
||||
# Passing ``client`` enables sampling; the approval callback gates it.
|
||||
client=OpenAIChatClient(),
|
||||
sampling_approval_callback=approve_sampling,
|
||||
sampling_max_tokens=2048,
|
||||
sampling_max_requests=5,
|
||||
),
|
||||
) as agent:
|
||||
query = "Use your MCP tool to help answer this question."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -3,7 +3,7 @@
|
||||
This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `Agent` using the **middleware** approach.
|
||||
|
||||
**What this sample demonstrates:**
|
||||
1. Configure an Azure OpenAI chat client
|
||||
1. Configure a Foundry chat client
|
||||
2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`)
|
||||
3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`)
|
||||
4. Implement a custom cache provider for advanced caching scenarios
|
||||
@@ -17,8 +17,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://<name>.openai.azure.com) |
|
||||
| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) |
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Azure AI Foundry project endpoint, for example `https://<resource>.services.ai.azure.com/api/projects/<project>` |
|
||||
| `FOUNDRY_MODEL` | Optional | Model deployment name (defaults to `gpt-4o-mini`) |
|
||||
| `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication |
|
||||
| `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth |
|
||||
| `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication |
|
||||
@@ -31,7 +31,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
|
||||
Opens a browser on first run to sign in.
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT = "https://your-openai-instance.openai.azure.com"
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<resource>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:FOUNDRY_MODEL = "gpt-4o-mini"
|
||||
$env:PURVIEW_CLIENT_APP_ID = "00000000-0000-0000-0000-000000000000"
|
||||
```
|
||||
|
||||
@@ -64,22 +65,27 @@ If interactive auth is used, a browser window will appear the first time.
|
||||
|
||||
## 4. How It Works
|
||||
|
||||
The sample demonstrates three different scenarios:
|
||||
The sample demonstrates four integration scenarios. Each scenario runs the same three-message sequence via `run_policy_flow(...)`:
|
||||
|
||||
1. **good (cold cache)** - a benign prompt that exercises the cold-cache parallel ProtectionScopes warmup + foreground ProcessContent path.
|
||||
2. **expected block** - a sensitive prompt containing the Visa test credit card number `4111 1111 1111 1111`. If the tenant has a DLP policy for `Microsoft 365 Copilot and AI apps` targeting the Credit Card sensitive info type with a Block action, this prompt returns the configured `blocked_prompt_message` (default: `Prompt blocked by policy`). If no DLP policy applies, the prompt is allowed (the LLM may still decline on its own, but that is a model-level response, not a Purview block).
|
||||
3. **good (warm cache)** - a second benign prompt that exercises the warm-cache path. The custom cache provider scenario prints `Cache HIT` for the same protection-scopes key, confirming the cache and middleware state survive a prior block.
|
||||
|
||||
### A. Agent Middleware (`run_with_agent_middleware`)
|
||||
1. Builds an Azure OpenAI chat client (using the environment endpoint / deployment)
|
||||
1. Builds a Foundry chat client (using the environment project endpoint / deployment)
|
||||
2. Chooses credential mode (certificate vs interactive)
|
||||
3. Creates `PurviewPolicyMiddleware` with `PurviewSettings`
|
||||
4. Injects middleware into the agent at construction
|
||||
5. Sends two user messages sequentially
|
||||
6. Prints results (or policy block messages)
|
||||
5. Runs the three-message `good -> block -> good` orchestration
|
||||
6. Prints `ALLOWED` or `BLOCKED` per message, plus the model response
|
||||
7. Uses default caching automatically
|
||||
|
||||
### B. Chat Client Middleware (`run_with_chat_middleware`)
|
||||
1. Creates a chat client with `PurviewChatPolicyMiddleware` attached directly
|
||||
2. Policy evaluation happens at the chat client level rather than agent level
|
||||
3. Demonstrates an alternative integration point for Purview policies
|
||||
4. Uses default caching automatically
|
||||
4. Runs the same `good -> block -> good` orchestration
|
||||
5. Uses default caching automatically
|
||||
|
||||
### C. Custom Cache Provider (`run_with_custom_cache_provider`)
|
||||
1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`)
|
||||
@@ -88,9 +94,27 @@ The sample demonstrates three different scenarios:
|
||||
- `async def get(self, key: str) -> Any | None`
|
||||
- `async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None`
|
||||
- `async def remove(self, key: str) -> None`
|
||||
4. Runs the `good -> block -> good` orchestration and prints `Cache MISS`/`Cache HIT` traces alongside policy outcomes, showing the cold-cache warmup populating the cache and warm-cache requests skipping ProtectionScopes.
|
||||
|
||||
### D. Default Cache (`run_with_default_cache`)
|
||||
1. Same as the agent middleware path but with explicit cache TTL and size limits in `PurviewSettings`
|
||||
2. Uses the default in-memory `CacheProvider`
|
||||
3. Runs the `good -> block -> good` orchestration
|
||||
|
||||
**Policy Behavior:**
|
||||
Prompt blocks set a system-level message: `Prompt blocked by policy` and terminate the run early. Response blocks rewrite the output to `Response blocked by policy`.
|
||||
Prompt blocks substitute the configured `blocked_prompt_message` (default `Prompt blocked by policy`) and terminate the agent run early. Response blocks substitute `blocked_response_message`. The LLM is never called for a blocked prompt.
|
||||
|
||||
**Seeing a real `BLOCKED` outcome:**
|
||||
The middle prompt only returns `BLOCKED` if the tenant actually has a Purview DLP policy that matches the request. Specifically, all of the following must be true:
|
||||
|
||||
1. The Entra app id used by `PURVIEW_CLIENT_APP_ID` (the same id Agent Framework sends as `policyLocationApplication.value`) is registered as an integrated AI app in Purview (Settings -> AI app and agent locations).
|
||||
2. A DLP policy in the tenant targets the location `Microsoft 365 Copilot and AI apps`, scoped to that app id (or `All apps`).
|
||||
3. The policy has a rule with the condition `Content contains -> Sensitive info types -> Credit Card Number` and an action of `Restrict access to Microsoft 365 Copilot and AI apps -> Block`.
|
||||
4. The policy is `On` (not `Test mode without notifications`).
|
||||
5. The signed-in user is in the policy's user scope.
|
||||
6. Required Graph delegated permissions are admin-consented: `ProtectionScopes.Compute.All`, `Content.Process.All`, `ContentActivity.Write`.
|
||||
|
||||
If any of those are missing, the credit card prompt is allowed at the Purview layer. The model itself may still decline on its own; that response is a model-level refusal, not a Purview block. The cold/warm cache orchestration is still demonstrated either way - the `Cache MISS -> Cache HIT` trace from the custom cache scenario does not depend on a block firing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Shows:
|
||||
Note: Caching is automatic and enabled by default.
|
||||
|
||||
Environment variables:
|
||||
- AZURE_OPENAI_ENDPOINT (required)
|
||||
- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini)
|
||||
- FOUNDRY_PROJECT_ENDPOINT (required) - Azure AI Foundry project endpoint URL
|
||||
- FOUNDRY_MODEL (optional, defaults to gpt-4o-mini)
|
||||
- PURVIEW_CLIENT_APP_ID (required)
|
||||
- PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth)
|
||||
- PURVIEW_TENANT_ID (required if certificate auth)
|
||||
@@ -45,6 +45,37 @@ load_dotenv()
|
||||
JOKER_NAME = "Joker"
|
||||
JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise."
|
||||
|
||||
# Sequential prompts to demonstrate good -> block -> good orchestration.
|
||||
# The sensitive prompt contains a Visa test credit card number that matches Purview's
|
||||
# built-in Credit Card sensitive information type. If the tenant has a DLP policy that
|
||||
# blocks credit card content for Microsoft 365 Copilot and AI apps, the second message
|
||||
# will be blocked and the third will verify that subsequent calls still flow normally
|
||||
# after a block.
|
||||
GOOD_PROMPT_PRIMARY = "Tell me a joke about a pirate."
|
||||
SENSITIVE_PROMPT = "My corporate credit card is 4111 1111 1111 1111. Please confirm receipt."
|
||||
GOOD_PROMPT_FOLLOWUP = "Another light joke please."
|
||||
|
||||
|
||||
async def run_policy_flow(
|
||||
label: str,
|
||||
agent: Agent,
|
||||
user_id: str | None,
|
||||
blocked_text: str,
|
||||
) -> None:
|
||||
"""Run a good -> block candidate -> good sequence and report each outcome."""
|
||||
blocked_marker = blocked_text.lower()
|
||||
prompts = [
|
||||
("good (cold cache)", GOOD_PROMPT_PRIMARY),
|
||||
("expected block", SENSITIVE_PROMPT),
|
||||
("good (warm cache)", GOOD_PROMPT_FOLLOWUP),
|
||||
]
|
||||
for tag, text in prompts:
|
||||
response: AgentResponse = await agent.run(
|
||||
Message("user", [text], additional_properties={"user_id": user_id})
|
||||
)
|
||||
outcome = "BLOCKED" if blocked_marker in str(response).lower() else "ALLOWED"
|
||||
print(f"[{label}] {tag}: {outcome}\n{response}\n")
|
||||
|
||||
|
||||
# Custom Cache Provider Implementation
|
||||
class SimpleDictCacheProvider:
|
||||
@@ -138,21 +169,17 @@ def build_credential() -> Any:
|
||||
|
||||
|
||||
async def run_with_agent_middleware() -> None:
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping run: AZURE_OPENAI_ENDPOINT not set")
|
||||
print("Skipping run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App",
|
||||
),
|
||||
)
|
||||
settings = PurviewSettings(app_name="Agent Framework Sample App")
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
@@ -162,39 +189,26 @@ async def run_with_agent_middleware() -> None:
|
||||
)
|
||||
|
||||
print("-- Agent MiddlewareTypes Path --")
|
||||
first: AgentResponse = await agent.run(
|
||||
Message("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("First response (agent middleware):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user", contents=["That was funny. Tell me another one."], additional_properties={"user_id": user_id}
|
||||
)
|
||||
)
|
||||
print("Second response (agent middleware):\n", second)
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("agent middleware", agent, user_id, blocked_text)
|
||||
|
||||
|
||||
async def run_with_chat_middleware() -> None:
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set")
|
||||
print("Skipping chat middleware run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini")
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", default="gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
|
||||
settings = PurviewSettings(app_name="Agent Framework Sample App (Chat)")
|
||||
client = FoundryChatClient(
|
||||
model=deployment,
|
||||
endpoint=endpoint,
|
||||
project_endpoint=endpoint,
|
||||
credential=AzureCliCredential(),
|
||||
middleware=[
|
||||
PurviewChatPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Chat)",
|
||||
),
|
||||
)
|
||||
PurviewChatPolicyMiddleware(build_credential(), settings)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -205,43 +219,27 @@ async def run_with_chat_middleware() -> None:
|
||||
)
|
||||
|
||||
print("-- Chat MiddlewareTypes Path --")
|
||||
first: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=["Give me a short clean joke."],
|
||||
additional_properties={"user_id": user_id},
|
||||
)
|
||||
)
|
||||
print("First response (chat middleware):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=["One more please."],
|
||||
additional_properties={"user_id": user_id},
|
||||
)
|
||||
)
|
||||
print("Second response (chat middleware):\n", second)
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("chat middleware", agent, user_id, blocked_text)
|
||||
|
||||
|
||||
async def run_with_custom_cache_provider() -> None:
|
||||
"""Demonstrate implementing and using a custom cache provider."""
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set")
|
||||
print("Skipping custom cache provider run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
custom_cache = SimpleDictCacheProvider()
|
||||
|
||||
settings = PurviewSettings(app_name="Agent Framework Sample App (Custom Provider)")
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Custom Provider)",
|
||||
),
|
||||
settings,
|
||||
cache_provider=custom_cache,
|
||||
)
|
||||
|
||||
@@ -254,38 +252,28 @@ async def run_with_custom_cache_provider() -> None:
|
||||
|
||||
print("-- Custom Cache Provider Path --")
|
||||
print("Using SimpleDictCacheProvider")
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("custom cache", agent, user_id, blocked_text)
|
||||
|
||||
first: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user", contents=["Tell me a joke about a programmer."], additional_properties={"user_id": user_id}
|
||||
)
|
||||
)
|
||||
print("First response (custom provider):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("Second response (custom provider):\n", second)
|
||||
|
||||
async def run_with_default_cache() -> None:
|
||||
"""Demonstrate using the default built-in cache."""
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set")
|
||||
print("Skipping default cache run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
# No cache_provider specified - uses default InMemoryCacheProvider
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Default Cache)",
|
||||
cache_ttl_seconds=3600,
|
||||
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
|
||||
),
|
||||
settings = PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Default Cache)",
|
||||
cache_ttl_seconds=3600,
|
||||
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
|
||||
)
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
@@ -296,16 +284,8 @@ async def run_with_custom_cache_provider() -> None:
|
||||
|
||||
print("-- Default Cache Path --")
|
||||
print("Using default InMemoryCacheProvider with settings-based configuration")
|
||||
|
||||
first: AgentResponse = await agent.run(
|
||||
Message("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("First response (default cache):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("Second response (default cache):\n", second)
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("default cache", agent, user_id, blocked_text)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -326,6 +306,11 @@ async def main() -> None:
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
print(f"Custom cache provider path failed: {ex}")
|
||||
|
||||
try:
|
||||
await run_with_default_cache()
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
print(f"Default cache path failed: {ex}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
Reference in New Issue
Block a user