Compare commits

..
Author SHA1 Message Date
Chetan Toshniwal 5e65fc42d1 ci: harden project status sync workflow 2026-05-11 17:30:51 -07:00
b6b449088e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-10 23:34:28 -07:00
chetantoshniwalandGitHub b943ca9fa1 Add GitHub Action to sync project status to labels
Workflow to Sync Status between Projects.
2026-05-10 23:28:04 -07:00
82 changed files with 1766 additions and 3472 deletions
+34 -22
View File
@@ -60,7 +60,6 @@ jobs:
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
- 'dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/**'
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
@@ -341,6 +340,7 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
targetFramework: net10.0
configuration: Release
steps:
- uses: actions/checkout@v6
@@ -357,15 +357,31 @@ jobs:
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
# Build the test csproj directly instead of a filtered slnx + -f override.
# The test project pins TargetFrameworks=net10.0 and its ProjectReference closure
# gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked
# exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock
# collisions caused by parallel inner-builds racing on shared bin/obj output paths
# under the previous slnx + global TFM override approach.
- name: Generate test solution (no samples)
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/agent-framework-dotnet.slnx `
-TargetFramework $env:targetFramework `
-Configuration $env:configuration `
-ExcludeSamples `
-OutputPath dotnet/filtered.slnx `
-Verbose
- name: Generate Foundry hosted IT filtered solution
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/filtered.slnx `
-TargetFramework $env:targetFramework `
-Configuration $env:configuration `
-TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
-OutputPath dotnet/filtered-foundry-hosted.slnx `
-Verbose
- name: Build Foundry hosted IT (and its deps)
shell: bash
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
@@ -378,12 +394,13 @@ jobs:
# are picked up; the image tag is content-hashed across the test container source AND its
# framework project references, so identical content is a no-op push.
#
# The script always passes --no-dependencies to dotnet publish so publish never re-touches
# the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced.
# This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild
# would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild
# step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference
# resolution both depend on the prebuilt outputs being present.
# `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
# rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
# (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
# another process") collisions caused by the previous build's shared-compilation server
# still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
# the same job against the same source. Do not remove the prebuild step (the subsequent
# `dotnet test --no-build` step depends on it too).
- name: Build and push Foundry Hosted Agents test container
id: build-foundry-hosted-image
shell: pwsh
@@ -393,13 +410,14 @@ jobs:
if ([string]::IsNullOrWhiteSpace($registry)) {
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
}
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
- name: Run Foundry Hosted Agents Integration Tests
shell: pwsh
working-directory: dotnet
run: |
dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj `
dotnet test --solution ./filtered-foundry-hosted.slnx `
-f $env:targetFramework `
-c $env:configuration `
--no-build -v Normal `
--report-xunit-trx `
@@ -408,12 +426,6 @@ jobs:
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
# Azure AI Search (for the azure-search-rag scenario). Reuses the integration
# environment secrets shared with python-sample-validation.yml. The index is
# provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
# for the required schema and seed content.
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
+159
View File
@@ -0,0 +1,159 @@
name: Sync Project Status to Labels
on:
projects_v2_item:
types: [edited]
# Prevent race conditions when status changes rapidly.
# Key by project item (node_id) so updates for the same card serialize.
concurrency:
group: status-sync-${{ github.event.projects_v2_item.node_id }}
cancel-in-progress: true
jobs:
sync_status:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v8
with:
# Use PAT/App token because Projects GraphQL often requires project scope.
# GITHUB_TOKEN is repo-scoped and may not access org Projects. 【4-75ee64】【5-e66679】
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const item = context.payload.projects_v2_item;
const changes = context.payload.changes || {};
// 1) Logging project id so that we can filter out by project in next revision.
console.log(`Processing issue from project: ${item.project_node_id}`);
// 2) Only act on Issues
if (item.content_type !== "Issue") return;
// 3) Map project Status values to labels
const labelMap = {
"Planned": "status:planned",
"In Progress": "status:in-progress",
"In Review": "status:in-review",
"Done": "status:done"
};
const allStatusLabels = Object.values(labelMap);
// 4) Fast path: If this edit is a Status change and the payload includes "to.name", use it.
// Some payloads include field_value.to with { name, ... } for single-select fields. 【6-4092ed】【3-e3ddba】
let statusValue = null;
const fv = changes.field_value;
if (fv && fv.field_name === "Status" && fv.to && fv.to.name) {
statusValue = fv.to.name;
console.log(`Fast path: Status changed to "${statusValue}"`);
}
// 5) Otherwise, query GraphQL once to get both Issue number and Status field value.
if (!statusValue) {
try {
const result = await github.graphql(
`query($id: ID!) {
node(id: $id) {
... on ProjectV2Item {
content { ... on Issue { number } }
fieldValues(first: 50) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field { ... on ProjectV2SingleSelectField { name } }
}
}
}
}
}
}`,
{ id: item.node_id }
);
const node = result?.node;
const values = node?.fieldValues?.nodes ?? [];
statusValue = values.find(v => v.field?.name === "Status")?.name;
// If no status found, nothing to do.
if (!statusValue) {
console.log("No Status field value found in project item");
return;
}
// Fetch issue number from GraphQL content if present
var issue_number = node?.content?.number;
if (!issue_number) {
console.error("Could not extract issue number from GraphQL response");
return;
}
} catch (graphqlError) {
console.error(`GraphQL query failed: ${graphqlError.message}`);
throw graphqlError;
}
} else {
// If we used fast-path for status, we still need issue_number:
try {
const result = await github.graphql(
`query($id: ID!) { node(id: $id) { ... on Issue { number } } }`,
{ id: item.content_node_id }
);
var issue_number = result?.node?.number;
} catch (graphqlError) {
console.error(`Failed to fetch issue number: ${graphqlError.message}`);
throw graphqlError;
}
if (!issue_number) return;
}
const targetLabel = labelMap[statusValue];
if (!targetLabel) {
console.warn(`Status "${statusValue}" has no mapped label. Skipping.`);
return;
}
console.log(`Mapped status "${statusValue}" to label "${targetLabel}"`);
// 6) Get existing labels
let issue;
try {
const response = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number
});
issue = response.data;
} catch (restError) {
console.error(`Failed to fetch issue #${issue_number}: ${restError.message}`);
throw restError;
}
const existingLabels = issue.labels.map(l => l.name);
// If already correct, exit (reduces churn)
if (existingLabels.includes(targetLabel) &&
existingLabels.filter(l => allStatusLabels.includes(l)).length === 1) {
console.log(`Issue #${issue_number} already has correct label "${targetLabel}". No changes needed.`);
return;
}
// 7) Avoid "remove then add" partial failure by using setLabels once.
// This preserves all non-status labels and ensures exactly one status label.
const nextLabels = existingLabels
.filter(l => !allStatusLabels.includes(l))
.concat([targetLabel]);
const removedLabels = existingLabels.filter(l => allStatusLabels.includes(l) && l !== targetLabel);
try {
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
labels: nextLabels
});
console.log(`Updated issue #${issue_number}: removed [${removedLabels.join(", ")}], added "${targetLabel}"`);
} catch (updateError) {
console.error(`Failed to update labels for issue #${issue_number}: ${updateError.message}`);
throw updateError;
}
-1
View File
@@ -25,7 +25,6 @@
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
+2 -9
View File
@@ -167,7 +167,7 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
@@ -313,9 +313,6 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
</Folder>
@@ -328,9 +325,6 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
</Folder>
@@ -338,7 +332,6 @@
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj" />
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
@@ -373,7 +366,7 @@
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
</Folder>
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
@@ -1,31 +0,0 @@
# Foundry Toolbox via MCP
This sample shows how to use a Foundry Toolbox by pointing an `McpClient` at the toolbox's MCP endpoint. The agent discovers the toolbox's tools at runtime and invokes them locally over MCP.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Passing the discovered MCP tools to `AIProjectClient.AsAIAgent(...)`
- Optional helper to create (or replace) a sample toolbox in the project so the sample is runnable end-to-end
## Prerequisites
- A Microsoft Foundry project with a toolbox configured (or let the sample create one for you)
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project/toolsets/research_toolbox/mcp?api-version=2025-05-01-preview"
```
The `<name>` segment of `FOUNDRY_TOOLBOX_ENDPOINT` must match the `ToolboxName` constant in `Program.cs`.
## Run the sample
```powershell
dotnet run
```
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -6,16 +6,12 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -1,80 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox via MCP (Streamable HTTP).
//
// Point an `McpClient` at a Foundry Toolbox's MCP endpoint. The agent
// discovers the toolbox's tools at runtime and invokes them locally.
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
// tools when creating an agent. The Foundry platform handles tool execution — the agent
// process does not invoke tools locally.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
// Must match the `<name>` segment of FOUNDRY_TOOLBOX_ENDPOINT.
// Replace with your own Foundry toolbox name.
const string ToolboxName = "research_toolbox";
const string Query = "What tools do you have access to?";
// Used only by CombineToolboxes — swap in a second toolbox you own.
const string SecondToolboxName = "analysis_toolbox";
// Replace with any question that exercises the tools configured in your toolbox.
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_ENDPOINT")
?? throw new InvalidOperationException(
"FOUNDRY_TOOLBOX_ENDPOINT is not set. Example: " +
"https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=2025-05-01-preview");
TokenCredential credential = new DefaultAzureCredential();
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
// Inject a fresh Azure AI bearer token on every MCP request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
Console.WriteLine($"Connecting to toolbox MCP endpoint: {toolboxEndpoint}");
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxEndpoint),
Name = "foundry_toolbox",
},
httpClient));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Toolbox MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
AIAgent agent = aiProjectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a helpful assistant. Use the available toolbox tools to answer the user.",
name: "ToolboxMcpAgent",
tools: [.. mcpTools.Cast<AITool>()]);
Console.WriteLine($"\nUser: {Query}\n");
Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
await Main(projectClient, model, endpoint);
// await CombineToolboxes(projectClient, model, endpoint);
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample runs end-to-end
// Main: single toolbox
// ---------------------------------------------------------------------------
static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
// Omit the version to resolve the toolbox's current default version at runtime.
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use the available tools to answer questions.",
tools: tools.ToList());
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Alternative: combine tools from multiple toolboxes
// ---------------------------------------------------------------------------
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Combine Toolboxes Example ===");
// Comment out if the toolboxes already exist in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
var allTools = toolboxA.Concat(toolboxB).ToList();
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use all available tools to answer questions.",
tools: allTools);
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
// ---------------------------------------------------------------------------
static async Task CreateSampleToolboxAsync(string name, string endpoint)
{
// Toolboxes are normally configured in the Foundry portal or a deployment
// script, not the application itself. This helper exists so the sample can
@@ -83,7 +96,10 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCr
// The Foundry-Features header is currently required for toolbox CRUD operations.
var options = new AgentAdministrationClientOptions();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, options);
var adminClient = new AgentAdministrationClient(
new Uri(endpoint),
new DefaultAzureCredential(),
options);
var toolboxClient = adminClient.GetAgentToolboxes();
// Delete existing toolbox if present (ignore 404).
@@ -112,7 +128,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCr
}
// ---------------------------------------------------------------------------
// Pipeline policy: adds the Foundry-Features header for toolbox CRUD calls
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
// ---------------------------------------------------------------------------
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
@@ -130,18 +146,3 @@ internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Azure AI bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,46 @@
# Agent_Step25_ToolboxServerSideTools
This sample demonstrates loading a named Foundry toolbox and passing its tools as
**server-side tools** when creating an agent via `AsAIAgent()`.
When tools from a toolbox are passed this way, they are sent as tool definitions in
the Responses API request. The Foundry platform handles tool execution — the agent
process does not invoke tools locally.
This is the dotnet equivalent of the Python sample:
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
## Prerequisites
- A Microsoft Foundry project
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
The sample recreates the toolbox on each run, replacing any existing toolbox with
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
an existing toolbox unchanged.
## How it works
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
Foundry project API (resolving the default version if none is specified)
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
API request as server-side tool definitions
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
## Sample flows
| Flow | Description |
|------|-------------|
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
Uncomment the desired flow in the top-level statements to try each one.
## Running the sample
```bash
dotnet run
```
@@ -73,7 +73,6 @@ Some samples require extra tool-specific environment variables. See each sample
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
## Running the samples
@@ -1,8 +0,0 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=<your-azure-search-endpoint>
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
@@ -1,17 +0,0 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
@@ -1,23 +0,0 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-azure-search-rag \
# -e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
# -e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
# --env-file .env hosted-azure-search-rag
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedAzureSearchRag</RootNamespace>
<AssemblyName>HostedAzureSearchRag</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -1,171 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to add Retrieval Augmented Generation (RAG) capabilities to a hosted
// agent using Azure AI Search. The sample assumes the search index has already been provisioned
// and populated out of band (see README.md for the required schema and example seed content).
// A SearchClient-backed adapter is plugged into TextSearchProvider, which runs a keyword search
// against the index before each model invocation and injects the matching documents into the
// model context.
using Azure;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string searchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set.");
string searchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set.");
// Use a chained credential. Try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in
// production). The dev credential is scope aware so a single instance serves both Foundry and
// Azure AI Search clients (each Azure SDK client requests a token for its own audience).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// Connect to the pre-provisioned search index. The caller is expected to have created the
// index and populated it with documents matching the schema (id / content / sourceName /
// sourceLink) before running this sample. See README.md for an example provisioning script.
var searchClient = new SearchClient(new Uri(searchEndpoint), searchIndexName, credential);
TextSearchProviderOptions textSearchOptions = new()
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-azure-search-rag",
ChatOptions = new ChatOptions
{
ModelId = deploymentName,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateSearchAdapter(searchClient), textSearchOptions)]
});
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
// ── Search adapter ───────────────────────────────────────────────────────────
// Wraps a SearchClient as the delegate TextSearchProvider expects. Keyword/full-text only;
// no embeddings. Returns the top results and projects them into TextSearchResult entries
// the provider will inject into the model context.
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var options = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, options, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
/// <summary>
/// A scope aware <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads pre-fetched bearer tokens from environment variables, dispensing the right token
/// based on the requested scope:
/// <list type="bullet">
/// <item><description><c>ai.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_FOUNDRY</c></description></item>
/// <item><description><c>search.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_SEARCH</c></description></item>
/// </list>
/// For any other scope, throws <see cref="CredentialUnavailableException"/> so a chained
/// credential will fall through. This should NOT be used in production: tokens expire (~1 hour)
/// and cannot be refreshed.
///
/// Generate the tokens on your host and pass them to the container:
/// <code>
/// export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN_FOUNDRY -e AZURE_BEARER_TOKEN_SEARCH ...
/// </code>
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string FoundryEnvironmentVariable = "AZURE_BEARER_TOKEN_FOUNDRY";
private const string SearchEnvironmentVariable = "AZURE_BEARER_TOKEN_SEARCH";
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> Resolve(requestContext.Scopes);
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(Resolve(requestContext.Scopes));
private static AccessToken Resolve(IReadOnlyList<string> scopes)
{
string? envVar = null;
foreach (var scope in scopes)
{
if (scope.Contains("search.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = SearchEnvironmentVariable;
break;
}
if (scope.Contains("ai.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = FoundryEnvironmentVariable;
break;
}
}
if (envVar is null)
{
throw new CredentialUnavailableException(
$"DevTemporaryTokenCredential cannot serve scopes [{string.Join(", ", scopes)}]; falling through.");
}
var token = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrEmpty(token) || string.Equals(token, "DefaultAzureCredential", StringComparison.Ordinal))
{
throw new CredentialUnavailableException(
$"{envVar} environment variable is not set; falling through to next credential.");
}
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -1,179 +0,0 @@
# Hosted-AzureSearchRag
A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities backed by **Azure AI Search**. The agent grounds its answers in product documentation by running a keyword search against an Azure AI Search index before each model invocation, then citing the source in its response.
This sample is the Azure AI Search counterpart to `Hosted-TextRag`. Where `Hosted-TextRag` uses a mock in-process search function, this sample talks to a real Azure AI Search index that is provisioned out of band (see "Provisioning the search index" below).
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Search service ([create one](https://learn.microsoft.com/azure/search/search-create-service-portal))
- **A pre-provisioned search index** with the schema and content described in the next section
- Azure CLI logged in (`az login`)
### Required RBAC
Your identity (or the Managed Identity running the container in production) needs:
- **Azure AI User** on the Foundry project scope
- **Search Index Data Reader** on the Azure AI Search service (the sample only reads from the index)
## Provisioning the search index (one time)
The sample assumes the search index already exists and contains documents the agent can retrieve from. Provision it once via the Azure Portal, the [REST API](https://learn.microsoft.com/azure/search/search-how-to-create-search-index), or the snippet below.
### Index schema
| Field | Type | Attributes |
|---|---|---|
| `id` | `Edm.String` | key, filterable |
| `content` | `Edm.String` | searchable (full-text) |
| `sourceName` | `Edm.String` | retrievable, filterable |
| `sourceLink` | `Edm.String` | retrievable |
### Example: provision and seed via Azure CLI + REST
```bash
SEARCH_ENDPOINT="https://<your-search>.search.windows.net"
INDEX_NAME="contoso-outdoors"
TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
# 1. Create the index.
curl -X PUT "$SEARCH_ENDPOINT/indexes/$INDEX_NAME?api-version=2024-07-01" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "contoso-outdoors",
"fields": [
{ "name": "id", "type": "Edm.String", "key": true, "filterable": true, "searchable": false, "retrievable": true },
{ "name": "content", "type": "Edm.String", "key": false, "filterable": false, "searchable": true, "retrievable": true, "analyzer": "standard.lucene" },
{ "name": "sourceName", "type": "Edm.String", "key": false, "filterable": true, "searchable": false, "retrievable": true },
{ "name": "sourceLink", "type": "Edm.String", "key": false, "filterable": false, "searchable": false, "retrievable": true }
]
}'
# 2. Upload three Contoso Outdoors documents matching the queries below.
curl -X POST "$SEARCH_ENDPOINT/indexes/$INDEX_NAME/docs/index?api-version=2024-07-01" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"value": [
{ "@search.action": "mergeOrUpload", "id": "return-policy", "sourceName": "Contoso Outdoors Return Policy", "sourceLink": "https://contoso.com/policies/returns", "content": "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection. As a thank you, every accepted return ships back with a complimentary Contoso TrailRunner sticker pack, item code TR-CANARY-7821." },
{ "@search.action": "mergeOrUpload", "id": "shipping-guide", "sourceName": "Contoso Outdoors Shipping Guide", "sourceLink": "https://contoso.com/help/shipping", "content": "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout. Use promo code SHIP-CANARY-4493 at checkout for a one-time free overnight upgrade on your first order." },
{ "@search.action": "mergeOrUpload", "id": "tent-care", "sourceName": "TrailRunner Tent Care Instructions", "sourceLink": "https://contoso.com/manuals/trailrunner-tent", "content": "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating. Replacement waterproofing kits are stocked under SKU TENT-CANARY-9067." }
]
}'
```
You can also point the sample at any existing index that exposes the four fields above; the sample reads `content`, `sourceName`, and `sourceLink` as projected by the search results.
## Configuration
Copy the template and fill in your endpoints:
```bash
cp .env.example .env
```
Edit `.env`:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=https://<your-search>.search.windows.net
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag
AGENT_NAME=hosted-azure-search-rag dotnet run
```
The agent will start on `http://localhost:8088`. The sample assumes the search index has already been provisioned and seeded (see "Provisioning the search index" above).
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What is your return policy?"
azd ai agent invoke --local "How long does shipping take?"
azd ai agent invoke --local "How do I clean my tent?"
```
Or with curl:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What is your return policy?", "model": "hosted-azure-search-rag"}'
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
```
### 3. Run the container
Generate two bearer tokens on your host (one per audience) and pass them to the container. A single Azure AD token has only one `aud` claim, so Foundry and Azure AI Search require separate tokens.
```bash
# Generate tokens (each expires in ~1 hour)
export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
# Run with both tokens
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-azure-search-rag \
-e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
-e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
--env-file .env \
hosted-azure-search-rag
```
### 4. Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What is your return policy?"
```
## How RAG works in this sample
The `TextSearchProvider` runs a keyword search against the configured Azure AI Search index **before each model invocation**. When the index is seeded with the three Contoso Outdoors documents from the provisioning section above:
| User query mentions | Search result injected |
|---|---|
| "return", "refund" | Contoso Outdoors Return Policy (canary token: `TR-CANARY-7821`) |
| "shipping", "promo" | Contoso Outdoors Shipping Guide (canary token: `SHIP-CANARY-4493`) |
| "tent", "fabric" | TrailRunner Tent Care Instructions (canary token: `TENT-CANARY-9067`) |
The model receives the top three search results as additional context and cites the source in its response. Each seeded document includes a unique `*-CANARY-*` token that does not exist in any model training data, so the integration tests can prove an answer was grounded in retrieved content (not fabricated from training) by asking for the canary and asserting it appears in the response.
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
@@ -1,31 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-azure-search-rag
displayName: "Hosted Azure AI Search RAG Agent"
description: >
A support specialist agent for Contoso Outdoors with RAG capabilities backed by
Azure AI Search. Uses TextSearchProvider with a SearchClient adapter to ground
answers in product documentation indexed in Azure AI Search before each model
invocation.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- RAG
- Azure AI Search
- Agent Framework
template:
name: hosted-azure-search-rag
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
parameters:
properties: []
resources: []
@@ -1,9 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-azure-search-rag
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -1,6 +0,0 @@
**/bin
**/obj
**/.vs
**/.vscode
.env
*.user
@@ -1,5 +0,0 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -1,17 +0,0 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedFiles.dll"]
@@ -1,19 +0,0 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-files .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-files -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-files
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedFiles.dll"]
@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedFiles</RootNamespace>
<AssemblyName>HostedFiles</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<ItemGroup>
<!-- Bake demo resources into the published output so the deployed agent's
tools can read them from /app/resources/ inside the container. -->
<Content Include="resources\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -1,223 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Files Agent - A hosted agent that exposes two distinct file knowledge sources
// through scoped, security-hardened tools:
//
// * Bundled files (image-baked) — files copied into the published output via the csproj
// <Content Include="resources\**"> rule. Live at /app/resources/ inside the container.
// Author-shipped knowledge that ships with every session.
//
// * Session files (per-session $HOME volume) — files uploaded at runtime via the alpha
// Azure.AI.Projects.AgentSessionFiles SDK. Live at $HOME inside the per-session
// container, which the platform sets to /home/session by default
// (container-image-spec.md line 127, "If you use the session files API, $HOME is
// also the base path for those operations").
//
// Each source is exposed via a separate tool pair, each rooted at its own directory.
// Tools take a fileName, not a path: Path.GetFileName strips any directory components,
// then a canonicalize + StartsWith(root) check enforces the boundary. The model cannot
// be tricked into reading /etc/passwd or any path outside its tool's root, even via
// indirect prompt injection in an uploaded file.
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
//
// Optional:
// AGENT_NAME - Agent name (default: hosted-files)
// BUNDLED_FILES_DIR - Override the bundled-files root
// (default: <baseDir>/resources, i.e. /app/resources/)
// HOME - Standard env var; the per-session sandbox volume
// (default: /home/session in the platform-managed container)
using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Load .env file if present (for local development)
Env.TraversePath().Load();
// Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values.
string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key);
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── File roots (canonicalized once) ──────────────────────────────────────────
// Bundled root: where csproj <Content Include="resources\**"> lands at runtime.
// In the container that resolves to /app/resources/.
string bundledRoot = Path.GetFullPath(
GetOptionalEnv("BUNDLED_FILES_DIR")
?? Path.Combine(AppContext.BaseDirectory, "resources"));
// Session root: the per-session $HOME volume mounted by the Foundry platform.
// Files uploaded via AgentSessionFiles.UploadSessionFileAsync(sessionStoragePath: "foo")
// land at $HOME/foo per container-image-spec.md line 172.
string sessionRoot = Path.GetFullPath(
GetOptionalEnv("HOME")
?? "/home/session");
// ── Tools: bundled files (image-baked, /app/resources/) ──────────────────────
[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")]
string ListBundledFiles() => SafeListNames(bundledRoot);
[Description("Read the full text contents of a bundled file by name. Bundled files are built-in knowledge shipped with the agent image.")]
string ReadBundledFile(
[Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName)
=> SafeRead(bundledRoot, fileName, scope: "bundled files");
// ── Tools: session files (per-session $HOME) ─────────────────────────────────
[Description("List the names of files uploaded into the current session sandbox by the user (e.g., via AgentSessionFiles.UploadSessionFileAsync).")]
string ListSessionFiles() => SafeListNames(sessionRoot);
[Description("Read the full text contents of a file uploaded into the current session by name. Session files are user-supplied data that lives only for the lifetime of this session.")]
string ReadSessionFile(
[Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName)
=> SafeRead(sessionRoot, fileName, scope: "session files");
// ── Path-safe helpers (defense-in-depth: GetFileName + canonicalize + StartsWith(root)) ──
string SafeListNames(string root)
{
try
{
if (!Directory.Exists(root))
{
return string.Empty;
}
return string.Join(
Environment.NewLine,
Directory.EnumerateFiles(root).Select(Path.GetFileName));
}
catch (Exception ex)
{
return $"Error listing files: {ex.Message}";
}
}
string SafeRead(string root, string fileName, string scope)
{
try
{
// Step 1: strip any directory components the model might have included.
string safeName = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(safeName))
{
return $"File '{fileName}' not found in {scope}.";
}
// Step 2: combine with the root and canonicalize.
string fullPath = Path.GetFullPath(Path.Combine(root, safeName));
// Step 3: enforce the prefix boundary so a crafted name still cannot escape.
string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal))
{
return $"File '{fileName}' not found in {scope}.";
}
return File.Exists(fullPath)
? File.ReadAllText(fullPath)
: $"File '{fileName}' not found in {scope}.";
}
catch (Exception ex)
{
return $"Error reading '{fileName}': {ex.Message}";
}
}
// ── Create and host the agent ────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(
model: deploymentName,
instructions: """
You are a friendly assistant that answers questions over two file sources:
- Bundled files: built-in knowledge that ships with the agent image
(e.g., reference reports the author packaged with you). Tools:
ListBundledFiles, ReadBundledFile.
- Session files: user-uploaded data for this session only (e.g., a CSV
the user wants you to analyse). Tools: ListSessionFiles, ReadSessionFile.
Pick the tool pair by intent. If a name could match either source, list
both first. Always read the file before answering; do not guess. Quote
numbers and figures verbatim from the file.
""",
name: GetOptionalEnv("AGENT_NAME") ?? "hosted-files",
description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.",
tools:
[
AIFunctionFactory.Create(ListBundledFiles),
AIFunctionFactory.Create(ReadBundledFile),
AIFunctionFactory.Create(ListSessionFiles),
AIFunctionFactory.Create(ReadSessionFile),
]);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
/// <summary>
/// A <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
/// once at startup. This should NOT be used in production.
///
/// Generate a token on your host and pass it to the container:
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
private readonly string? _token;
public DevTemporaryTokenCredential()
{
this._token = System.Environment.GetEnvironmentVariable(EnvironmentVariable);
}
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> this.GetAccessToken();
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(this.GetAccessToken());
private AccessToken GetAccessToken()
{
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
{
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
}
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -1,128 +0,0 @@
# Hosted-Files
A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools:
- **Bundled files** (image-baked) — files the author packages with the agent at build time. Live at `/app/resources/` inside the container, copied from this project's [`resources/`](./resources/) folder via the csproj `<Content Include="resources\**\*" CopyToOutputDirectory="PreserveNewest" />` rule.
- **Session files** (per-session `$HOME` volume) — files the user uploads at runtime via the alpha `Azure.AI.Projects.AgentSessionFiles` SDK. Live at `$HOME` inside the per-session container. The Foundry platform sets `HOME=/home/session` by default and roots the session-files API there per [`container-image-spec.md` line 172](https://github.com/microsoft/foundrysdk-specs/blob/main/specs/agents/hosted_agents/container-spec/docs/container-image-spec.md): *"If you use the session files API, `$HOME` is also the base path for those operations; any paths given in those API endpoints will be relative to `$HOME`."*
## Tool surface
Each source is exposed via its own tool pair, rooted at its own directory. The model picks by intent.
| Tool | Source | Root |
|------|--------|------|
| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` |
| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` |
| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session`) |
| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session`) |
## Security model — distinct tools, distinct sandboxes
Each tool takes a `fileName` (no directory components allowed) and enforces three layers of defence inside the implementation:
1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`.
2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path.
3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root.
Failures return a controlled `"File '<input>' not found in <scope>."` rather than throwing or exposing the canonical path.
This is why the agent has four narrowly-scoped tools instead of a single `ReadFile(path)`:
- **Smaller per-tool attack surface.** Each tool has one purpose, one root, and no path-typed parameter. Even a buggy implementation can only leak its own directory.
- **Cross-boundary access is impossible by schema.** A prompt-injection attempt to make the bundled tool read a session path (or vice versa) does not even compile in the tool schema the model sees.
- **Read-only, non-recursive listing.** No write tools, no glob, no `..`.
## Companion
[`Using-Samples/SessionFilesClient`](../Using-Samples/SessionFilesClient/) — a thin chat REPL (same shape as [`SimpleAgent`](../Using-Samples/SimpleAgent/)) that points at the deployed Hosted-Files endpoint via `FoundryAgent` and lets you ask questions whose answers come from either file source.
## Live proof of the session-files contract
The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.UploadSessionFileAsync` → file arrives at `$HOME/<name>` inside the per-session container → agent's `ReadSessionFile` tool reads it → response quotes the verbatim contents) is exercised live by [`SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync`](../../../../../tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs) against the matching `session-files` scenario in the integration test container.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your project endpoint:
```bash
cp .env.example .env
```
Edit `.env`:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
```
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files
AGENT_NAME=hosted-files dotnet run
```
The agent starts on `http://localhost:8088`.
## Try it from the SessionFilesClient REPL
### Bundled files (works against any deployment, including local)
```bash
cd ../Using-Samples/SessionFilesClient
$env:AGENT_ENDPOINT = "http://localhost:8088"
$env:AGENT_NAME = "hosted-files"
dotnet run
You> What is the total revenue in the contoso file?
Agent> The contoso file reports total revenue of "$1,482.6M".
```
The agent calls `ListBundledFiles`, sees `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (which resolves under `/app/resources/`), and quotes the figure verbatim.
### Session files (against a deployed agent)
Upload a file to a specific session via `azd ai agent files upload` or via the alpha `AgentSessionFiles` SDK (see the integration test for the SDK call), then ask the agent about it. The agent's `ReadSessionFile` tool reads from `$HOME` and surfaces the content the same way.
## Running with Docker
This project uses `ProjectReference`, so use `Dockerfile.contributor` which takes a pre-published output:
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
docker build -f Dockerfile.contributor -t hosted-files .
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-files \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-files
```
The bundled `resources/` folder is part of the published output and ships inside the image.
## NuGet package users
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
## Adding more bundled files
Drop additional text files into [`resources/`](./resources/). The csproj `<Content Include="resources\**\*" CopyToOutputDirectory="PreserveNewest" />` rule picks them up on the next `dotnet build` / `docker build`.
## Overrides
| Env var | Purpose | Default |
|---------|---------|---------|
| `BUNDLED_FILES_DIR` | Override the bundled-files root the tools read from. | `<process base dir>/resources` (`/app/resources/` in container) |
| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` |
@@ -1,30 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-files
displayName: "Hosted Files Agent"
description: >
A hosted agent that answers questions over a small set of files bundled
with its container image (under /app/resources/). Two local C# function
tools (ListFiles, ReadFile) surface the bundled file contents to the model.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Bundled Files
- Local Tools
- Agent Framework
template:
name: hosted-files
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
parameters:
properties: []
resources: []
@@ -1,9 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-files
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -1,121 +0,0 @@
Contoso Corporation
Quarterly Report — Q1 2026 (Three months ended March 31, 2026)
DISCLAIMER
This document contains fictional data for sample/demo purposes only.
Contoso is a fictional company; all figures below are fabricated.
------------------------------------------------------------
1. EXECUTIVE SUMMARY
------------------------------------------------------------
Contoso delivered a solid first quarter, with total revenue of
$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud
Services segment (+22.7% YoY) and continued double-digit expansion
in International markets. Operating margin expanded 140 basis points
to 23.8% on disciplined cost management and improved gross margin.
Key highlights:
- Revenue: $1,482.6M (YoY +11.4%)
- Gross profit: $912.0M (gross margin 61.5%)
- Operating income: $352.9M (operating margin 23.8%)
- Net income: $268.4M (net margin 18.1%)
- Diluted EPS: $1.27 (vs. $1.04 prior year)
- Free cash flow: $311.5M
- Cash & equivalents: $2,140.8M
------------------------------------------------------------
2. INCOME STATEMENT (USD millions, unaudited)
------------------------------------------------------------
Q1 2026 Q1 2025 YoY %
Revenue 1,482.6 1,330.7 +11.4%
Cost of revenue 570.6 538.9 +5.9%
Gross profit 912.0 791.8 +15.2%
Gross margin 61.5% 59.5% +200 bps
Operating expenses
Research & development 241.4 220.5 +9.5%
Sales & marketing 218.7 205.1 +6.6%
General & administrative 99.0 88.6 +11.7%
Total operating expenses 559.1 514.2 +8.7%
Operating income 352.9 277.6 +27.1%
Operating margin 23.8% 20.9% +290 bps
Other income / (expense), net 8.4 5.1
Income before taxes 361.3 282.7
Provision for income taxes 92.9 72.6
Net income 268.4 210.1 +27.7%
Diluted EPS (USD) 1.27 1.04 +22.1%
------------------------------------------------------------
3. REVENUE BY SEGMENT (USD millions)
------------------------------------------------------------
Segment Q1 2026 Q1 2025 YoY %
Cloud Services 612.4 499.1 +22.7%
Productivity Software 448.9 422.6 +6.2%
Devices & Hardware 267.0 260.4 +2.5%
Professional Services 154.3 148.6 +3.8%
Total revenue 1,482.6 1,330.7 +11.4%
------------------------------------------------------------
4. REVENUE BY GEOGRAPHY (USD millions)
------------------------------------------------------------
Region Q1 2026 Q1 2025 YoY %
North America 812.1 756.0 +7.4%
EMEA 388.5 340.2 +14.2%
Asia-Pacific 221.7 183.4 +20.9%
Latin America 60.3 51.1 +18.0%
Total revenue 1,482.6 1,330.7 +11.4%
------------------------------------------------------------
5. SELECTED BALANCE SHEET ITEMS (USD millions)
------------------------------------------------------------
Mar 31, Dec 31,
2026 2025
Cash & equivalents 2,140.8 1,902.3
Short-term investments 845.6 820.4
Accounts receivable, net 1,012.7 988.5
Total current assets 4,510.2 4,190.6
Goodwill & intangibles 2,330.1 2,338.9
Total assets 9,884.5 9,512.0
Total current liabilities 2,118.4 2,054.7
Long-term debt 1,750.0 1,750.0
Total liabilities 4,402.6 4,310.5
Total stockholders' equity 5,481.9 5,201.5
------------------------------------------------------------
6. CASH FLOW HIGHLIGHTS (USD millions)
------------------------------------------------------------
Q1 2026 Q1 2025
Net cash from operating activities 382.0 298.7
Capital expenditures (70.5) (62.1)
Free cash flow 311.5 236.6
Share repurchases (120.0) (90.0)
Dividends paid (54.2) (48.6)
------------------------------------------------------------
7. KEY OPERATING METRICS
------------------------------------------------------------
Cloud paid seats (millions) 48.6 39.7 +22.4%
Cloud net revenue retention 118% 114%
Active enterprise customers 18,420 16,905 +9.0%
Headcount (end of period) 22,140 20,610 +7.4%
------------------------------------------------------------
8. OUTLOOK — Q2 2026 GUIDANCE
------------------------------------------------------------
Revenue: $1,520M $1,560M (YoY +10% to +13%)
Operating margin: 23.5% 24.5%
Diluted EPS: $1.30 $1.36
Capital expenditures: ~$80M
Management remains confident in the full-year plan and reiterates
fiscal-year 2026 revenue growth of 1012% and operating-margin
expansion of 100150 basis points versus FY 2025.
------------------------------------------------------------
9. NOTES
------------------------------------------------------------
- All figures are unaudited and rounded to one decimal place.
- Year-over-year comparisons are versus the same period in 2025.
- "Free cash flow" is defined as net cash from operating activities
less capital expenditures, and is a non-GAAP measure.
- This sample report is intended solely for demonstration of an
agent-driven document analysis pipeline.
@@ -1,116 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.Identity;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
// Load .env file if present (for local development)
Env.TraversePath().Load();
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
?? "http://localhost:8088");
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
?? throw new InvalidOperationException("AGENT_NAME is not set.");
// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
var options = new AIProjectClientOptions();
if (agentEndpoint.Scheme == "http")
{
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
// before the request hits the wire.
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
}
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
AgentSession session = await agent.CreateSessionAsync();
// ── REPL ──────────────────────────────────────────────────────────────────────
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"""
══════════════════════════════════════════════════════════
Session Files Client
Connected to: {agentEndpoint}
Try: "Give me the total revenue in the contoso file."
Type a message or 'quit' to exit
""");
Console.ResetColor();
Console.WriteLine();
while (true)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("You> ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) { continue; }
if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; }
try
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("Agent> ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(input, session))
{
Console.Write(update);
}
Console.WriteLine();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
}
Console.WriteLine();
}
Console.WriteLine("Goodbye!");
/// <summary>
/// For Local Development Only
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
/// </summary>
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
RewriteScheme(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
RewriteScheme(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private static void RewriteScheme(PipelineMessage message)
{
var uri = message.Request.Uri!;
if (uri.Scheme == Uri.UriSchemeHttps)
{
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
}
}
}
@@ -1,50 +0,0 @@
# SessionFilesClient
A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run.
The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry)
- Azure CLI logged in (`az login`)
## Configuration
```env
AGENT_ENDPOINT=http://localhost:8088
AGENT_NAME=hosted-files
```
`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
## Run
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
$env:AGENT_ENDPOINT = "http://localhost:8088"
$env:AGENT_NAME = "hosted-files"
dotnet run
```
## End-to-end demo
With the [`Hosted-Files`](../../Hosted-Files/) agent running:
```text
══════════════════════════════════════════════════════════
Session Files Client
Connected to: http://localhost:8088/
Try: "Give me the total revenue in the contoso file."
Type a message or 'quit' to exit
══════════════════════════════════════════════════════════
You> Give me the total revenue in the contoso file.
Agent> The contoso file reports total revenue of "$1,482.6M".
You> quit
Goodbye!
```
The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt.
@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>SessionFilesClient</RootNamespace>
<AssemblyName>session-files-client</AssemblyName>
<NoWarn>$(NoWarn);NU1605;OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -105,7 +105,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
@@ -1,106 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Endpoint filter that enforces the DevUI security posture: loopback-only
/// access by default, plus optional bearer-token authentication.
/// </summary>
internal sealed class DevUIAuthFilter : IEndpointFilter
{
private const string BearerScheme = "Bearer";
private readonly DevUIOptions _options;
private readonly byte[]? _expectedTokenBytes;
private readonly ILogger<DevUIAuthFilter> _logger;
/// <summary>
/// Gets a value indicating whether a bearer token is required by this filter
/// (either via <see cref="DevUIOptions.AuthToken"/> or the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable).
/// </summary>
public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };
public DevUIAuthFilter(IOptions<DevUIOptions> options, ILogger<DevUIAuthFilter> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
this._options = options.Value;
this._logger = logger;
var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken)
? this._options.AuthToken
: Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable);
this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
? Encoding.UTF8.GetBytes(configuredToken)
: null;
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
var remoteIp = httpContext.Connection.RemoteIpAddress;
var isLoopback = remoteIp is not null && IPAddress.IsLoopback(remoteIp);
if (!isLoopback && !this._options.AllowRemoteAccess)
{
this._logger.LogWarning(
"Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.",
remoteIp);
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "DevUI access denied",
detail: "DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access.");
}
if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(httpContext.Request, expected))
{
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme;
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "DevUI authentication required",
detail: "Provide a valid bearer token via the Authorization header.");
}
return await next(context).ConfigureAwait(false);
}
private static bool TokenIsValid(HttpRequest request, byte[] expected)
{
if (!request.Headers.TryGetValue(HeaderNames.Authorization, out var headerValues))
{
return false;
}
foreach (var header in headerValues)
{
if (string.IsNullOrEmpty(header))
{
continue;
}
const int PrefixLength = 7; // "Bearer "
if (header.Length <= PrefixLength ||
!header.StartsWith(BearerScheme, StringComparison.OrdinalIgnoreCase) ||
header[BearerScheme.Length] != ' ')
{
continue;
}
var presented = Encoding.UTF8.GetBytes(header.AsSpan(PrefixLength).Trim().ToString());
if (CryptographicOperations.FixedTimeEquals(presented, expected))
{
return true;
}
}
return false;
}
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Options;
namespace Microsoft.Agents.AI.DevUI;
@@ -14,19 +13,12 @@ public static class DevUIExtensions
/// Maps an endpoint that serves the DevUI from the '/devui' path.
/// </summary>
/// <remarks>
/// <para>
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
/// and the corresponding endpoints to be mapped using
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
/// </para>
/// <para>
/// DevUI is restricted to loopback callers unless
/// <see cref="DevUIOptions.AllowRemoteAccess"/> is set. See <see cref="DevUIOptions"/>
/// for the available authentication and authorization hooks.
/// </para>
/// </remarks>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
@@ -38,29 +30,11 @@ public static class DevUIExtensions
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var authFilter = endpoints.ServiceProvider.GetRequiredService<DevUIAuthFilter>();
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<DevUIOptions>>().Value;
var startupLogger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIAuthFilter>>();
WarnIfInsecurelyExposed(startupLogger, options);
// /meta must remain reachable without authentication so the frontend can
// discover whether a bearer token is required before prompting for one.
endpoints.MapMeta(authRequired: authFilter.TokenRequired);
var protectedGroup = endpoints.MapGroup("");
// Conventions must be applied before endpoints are added to the group so
// they reliably attach to every protected DevUI endpoint.
options.ConfigureEndpoints?.Invoke(protectedGroup);
protectedGroup.AddEndpointFilter(authFilter);
protectedGroup.MapDevUI(pattern: "/devui");
protectedGroup.MapEntities();
return protectedGroup;
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapMeta();
group.MapEntities();
return group;
}
/// <summary>
@@ -92,18 +66,4 @@ public static class DevUIExtensions
.WithName($"DevUI at {cleanPattern}")
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
}
private static void WarnIfInsecurelyExposed(ILogger logger, DevUIOptions options)
{
var tokenConfigured = !string.IsNullOrEmpty(options.AuthToken)
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable));
if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
{
logger.LogWarning(
"DevUI is configured with AllowRemoteAccess=true and no authentication. " +
"Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.",
DevUIOptions.AuthTokenEnvironmentVariable);
}
}
}
@@ -1,59 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Options that control the security posture of the DevUI HTTP surface.
/// </summary>
/// <remarks>
/// DevUI exposes agent metadata that is sensitive in production contexts:
/// system instructions, tool definitions, model identifiers, and workflow
/// structure. By default, DevUI rejects any request whose remote endpoint
/// is not a loopback address. Hosts that intentionally expose DevUI on a
/// non-loopback interface must opt in via <see cref="AllowRemoteAccess"/>
/// and should also configure <see cref="AuthToken"/> or
/// <see cref="ConfigureEndpoints"/> to attach an authorization policy.
/// </remarks>
public sealed class DevUIOptions
{
/// <summary>
/// Environment variable inspected for a default bearer token when
/// <see cref="AuthToken"/> is not explicitly set.
/// </summary>
public const string AuthTokenEnvironmentVariable = "DEVUI_AUTH_TOKEN";
/// <summary>
/// Gets or sets a value indicating whether DevUI may be served to
/// non-loopback callers. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// When <see langword="false"/>, any request whose
/// <see cref="ConnectionInfo.RemoteIpAddress"/> is
/// not a loopback address (or is missing) is rejected with HTTP 403 before
/// reaching the DevUI handlers. Enable only when the host is responsible
/// for fronting DevUI with its own authentication, network policy, or both.
/// </remarks>
public bool AllowRemoteAccess { get; set; }
/// <summary>
/// Gets or sets a shared bearer token required on every DevUI request.
/// When <see langword="null"/> or empty, the value of the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable is used instead.
/// </summary>
/// <remarks>
/// When a token is configured, requests must include the header
/// <c>Authorization: Bearer &lt;token&gt;</c>. Comparison is performed
/// in constant time. This is a convenience for development scenarios.
/// Production hosts should prefer a real ASP.NET Core authentication
/// scheme attached via <see cref="ConfigureEndpoints"/>.
/// </remarks>
public string? AuthToken { get; set; }
/// <summary>
/// Gets or sets a callback invoked with the DevUI endpoint group so the
/// host can attach authorization, rate limiting, or other endpoint
/// conventions (for example
/// <c>group.RequireAuthorization("DevUIPolicy")</c>).
/// </summary>
public Action<IEndpointConventionBuilder>? ConfigureEndpoints { get; set; }
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DevUI;
namespace Microsoft.Extensions.Hosting;
/// <summary>
@@ -15,19 +13,10 @@ public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
=> AddDevUI(builder, configure: null);
/// <summary>
/// Adds DevUI services to the host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action<DevUIOptions>? configure)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddDevUI(configure);
builder.Services.AddDevUI();
return builder;
}
@@ -13,7 +13,6 @@ internal static class MetaApiExtensions
/// Maps the HTTP API endpoint for retrieving server metadata.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="authRequired">Value reported via <c>auth_required</c> in the meta response so the frontend can decide whether to prompt for a bearer token.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoint:
@@ -23,16 +22,16 @@ internal static class MetaApiExtensions
/// The endpoint is compatible with the Python DevUI frontend and provides essential
/// configuration information needed for proper frontend initialization.
/// </remarks>
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints, bool authRequired = false)
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGet("/meta", () => GetMeta(authRequired))
return endpoints.MapGet("/meta", GetMeta)
.WithName("GetMeta")
.WithSummary("Get server metadata and configuration")
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
}
private static IResult GetMeta(bool authRequired)
private static IResult GetMeta()
{
// TODO: Consider making these configurable via IOptions<DevUIOptions>
// For now, using sensible defaults that match Python DevUI behavior
@@ -54,7 +53,7 @@ internal static class MetaApiExtensions
// Deployment capability - not currently supported in .NET DevUI
["deployment"] = false
},
AuthRequired = authRequired
AuthRequired = false // Could be made configurable based on authentication middleware
};
return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
@@ -2,9 +2,6 @@
This package provides a web interface for testing and debugging AI agents during development.
> [!WARNING]
> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI rejects any request whose remote endpoint is not a loopback address; see [Security](#security) below for the available options.
## Installation
```bash
@@ -51,30 +48,3 @@ if (builder.Environment.IsDevelopment())
app.Run();
```
## Security
DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that:
- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default.
- Optionally requires a shared bearer token on every request.
Configure via `DevUIOptions`:
```csharp
builder.AddDevUI(options =>
{
// Allow non-loopback callers. Set this only when the host fronts DevUI with
// its own authentication or network policy.
options.AllowRemoteAccess = true;
// Optional: require Authorization: Bearer <token> on every request.
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
options.AuthToken = builder.Configuration["DevUI:AuthToken"];
// Optional: attach a real authorization policy or rate limiting.
options.ConfigureEndpoints = group => group.RequireAuthorization("DevUIPolicy");
});
```
The bundled bearer-token check uses constant-time comparison and is intended as a convenience for development scenarios. Production hosts should prefer a real ASP.NET Core authentication scheme via `ConfigureEndpoints`.
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Shared.Diagnostics;
@@ -18,26 +17,9 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services)
=> AddDevUI(services, configure: null);
/// <summary>
/// Adds services required for DevUI integration.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services, Action<DevUIOptions>? configure)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<DevUIOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
services.AddSingleton<DevUIAuthFilter>();
// a factory that tries to construct an AIAgent from Workflow,
// even if workflow was not explicitly registered as an AIAgent.
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// Provides a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AIProjectClientToolboxExtensions
{
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="System.ArgumentNullException">
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
this AIProjectClient projectClient,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectClient);
Throw.IfNullOrWhitespace(name);
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
}
@@ -0,0 +1,220 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
/// </summary>
/// <remarks>
/// <para>
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
{
/// <summary>
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically (requires an additional API call).
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
/// <summary>
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
/// suitable for use as server-side tools in the Responses API.
/// </summary>
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
/// platform handles their execution.
/// </para>
/// <para>
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
/// that the toolbox API returns but the Responses API rejects.
/// </para>
/// </remarks>
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
{
Throw.IfNull(toolboxVersion);
if (toolboxVersion.Tools?.Any() != true)
{
return [];
}
return toolboxVersion.Tools
.Select(SanitizeAndConvert)
.ToList();
}
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
/// <summary>
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
/// </summary>
/// <remarks>
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
/// these decoration fields for non-function tools. Function tools keep them since
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
/// </remarks>
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
{
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
var node = JsonNode.Parse(toolJson.ToString());
if (node is not JsonObject obj)
{
return ((ResponseTool)tool).AsAITool();
}
var toolType = obj["type"]?.GetValue<string>();
// Function tools need name/description — don't strip
if (toolType is "function" or "custom")
{
return ((ResponseTool)tool).AsAITool();
}
// Strip decoration fields that the Responses API rejects
bool modified = false;
modified |= obj.Remove("name");
modified |= obj.Remove("description");
if (!modified)
{
return ((ResponseTool)tool).AsAITool();
}
var sanitizedJson = obj.ToJsonString();
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
return sanitizedTool.AsAITool();
}
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version,
AgentAdministrationClientOptions? clientOptions,
CancellationToken cancellationToken)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
internal static AgentToolboxes CreateToolboxClient(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
AgentAdministrationClientOptions? clientOptions = null)
{
clientOptions ??= new AgentAdministrationClientOptions();
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
return adminClient.GetAgentToolboxes();
}
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
AgentToolboxes toolboxClient,
string name,
string? version,
CancellationToken cancellationToken)
{
if (version is null)
{
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
version = record.Value.DefaultVersion
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
}
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
return result.Value;
}
#endregion
}
@@ -42,15 +42,23 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
if (snapshot is not null)
if (snapshot is null)
{
// AsyncLocal mutations made inside an awaited async method do not leak back to the
// caller after the method returns, so we do not need an explicit restore step here.
// See ClientHeadersScope remarks.
ClientHeadersScope.Current = snapshot;
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
}
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
async Task<AgentResponse> RunAsyncCoreAsync(
IEnumerable<ChatMessage> innerMessages,
AgentSession? innerSession,
AgentRunOptions? innerOptions,
Dictionary<string, string> innerSnapshot,
CancellationToken innerCt)
{
using var _ = ClientHeadersScope.Push(innerSnapshot);
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
}
}
/// <inheritdoc/>
@@ -61,10 +69,7 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
if (snapshot is not null)
{
ClientHeadersScope.Current = snapshot;
}
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
@@ -11,31 +11,39 @@ namespace Microsoft.Agents.AI.Foundry;
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AsyncLocal{T}"/> propagates the value forward into every <c>await</c> on the same
/// async flow, but mutations made inside an awaited <c>async</c> method do <em>not</em> leak back
/// to the caller after the method returns. This means a method that assigns
/// <see cref="Current"/> at the top and then awaits inner work does not need any explicit
/// restoration step: the runtime restores the caller's view of the AsyncLocal automatically when
/// the method's task completes.
/// </para>
/// <para>
/// Setting <see cref="Current"/> from synchronous code, however, will leak to the caller because
/// no async-method boundary is crossed. All Agent Framework call sites of this carrier are
/// inside <c>async</c> methods (<see cref="ClientHeadersAgent"/>), so the natural restoration
/// suffices for our needs.
/// </para>
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
/// for nested or sequential per-call scopes on the same async flow.
/// </remarks>
internal static class ClientHeadersScope
{
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
/// <summary>
/// Gets or sets the per-async-flow client-header snapshot read by <see cref="ClientHeadersPolicy"/>.
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
/// </summary>
public static IReadOnlyDictionary<string, string>? Current
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
{
get => s_current.Value;
set => s_current.Value = value;
var previous = s_current.Value;
s_current.Value = headers;
return new Scope(previous);
}
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
internal readonly struct Scope : System.IDisposable
{
private readonly IReadOnlyDictionary<string, string>? _previous;
internal Scope(IReadOnlyDictionary<string, string>? previous)
{
this._previous = previous;
}
public void Dispose() => s_current.Value = this._previous;
}
}
@@ -54,9 +54,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
private bool _autonomousMode;
private string? _autonomousModePrompt;
private int? _autonomousModeTurnLimit;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -145,34 +142,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
/// <summary>
/// Enables autonomous mode for all agents in the workflow.
/// </summary>
/// <remarks>
/// In autonomous mode, when an agent responds without requesting a handoff, it is immediately
/// re-invoked with a synthetic user message (the <paramref name="prompt"/>) rather than
/// returning control to the user. The agent continues iterating until it requests a handoff
/// or the <paramref name="turnLimit"/> is reached. After the turn limit is exceeded, control
/// is returned to the user as in the default human-in-the-loop behavior.
/// </remarks>
/// <param name="prompt">
/// The message to inject as a user turn when re-invoking an agent in autonomous mode.
/// If <see langword="null"/>, a default prompt is used.
/// </param>
/// <param name="turnLimit">
/// The maximum number of autonomous continuation turns per agent per incoming turn.
/// The counter resets at the beginning of each new turn (each incoming <see cref="HandoffState"/>).
/// If <see langword="null"/>, the default limit is used.
/// </param>
/// <returns>The updated builder instance.</returns>
public TBuilder EnableAutonomousMode(string? prompt = null, int? turnLimit = null)
{
this._autonomousMode = true;
this._autonomousModePrompt = prompt;
this._autonomousModeTurnLimit = turnLimit;
return (TBuilder)this;
}
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
@@ -278,10 +247,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior,
autonomousMode: this._autonomousMode,
autonomousModePrompt: this._autonomousModePrompt,
autonomousModeTurnLimit: this._autonomousModeTurnLimit);
this._toolCallFilteringBehavior);
// There are two types of ids being used in this method, and it is critical that we are clear about
// which one we are using, and where.
@@ -15,22 +15,12 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
public HandoffAgentExecutorOptions(
string? handoffInstructions,
bool emitAgentResponseEvents,
bool? emitAgentResponseUpdateEvents,
HandoffToolCallFilteringBehavior toolCallFilteringBehavior,
bool autonomousMode = false,
string? autonomousModePrompt = null,
int? autonomousModeTurnLimit = null)
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
this.EmitAgentResponseEvents = emitAgentResponseEvents;
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
this.AutonomousMode = autonomousMode;
this.AutonomousModePrompt = autonomousModePrompt ?? HandoffAgentExecutor.DefaultAutonomousModePrompt;
this.AutonomousModeTurnLimit = autonomousModeTurnLimit ?? HandoffAgentExecutor.DefaultAutonomousModeTurnLimit;
}
public string? HandoffInstructions { get; set; }
@@ -40,23 +30,6 @@ internal sealed class HandoffAgentExecutorOptions
public bool? EmitAgentResponseUpdateEvents { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
/// <summary>
/// Gets or sets a value indicating whether the agent operates in autonomous mode.
/// In autonomous mode, the agent continues responding without user input until a handoff is requested or the turn limit is reached.
/// </summary>
public bool AutonomousMode { get; set; }
/// <summary>
/// Gets or sets the prompt to inject as a user message when continuing in autonomous mode.
/// </summary>
public string AutonomousModePrompt { get; set; }
/// <summary>
/// Gets or sets the maximum number of autonomous turns per incoming turn.
/// The counter is reset at the start of every new <see cref="HandoffState"/> turn.
/// </summary>
public int AutonomousModeTurnLimit { get; set; }
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
@@ -101,12 +74,6 @@ internal sealed record StateRef<TState>(string Key, string? ScopeName)
internal sealed class HandoffAgentExecutor :
StatefulExecutor<HandoffAgentHostState, HandoffState>
{
/// <summary>The default prompt injected as a user message when operating in autonomous mode and no handoff has been requested.</summary>
internal const string DefaultAutonomousModePrompt = "User did not respond. Continue assisting autonomously.";
/// <summary>The default maximum number of autonomous turns before control is returned to the user.</summary>
internal const int DefaultAutonomousModeTurnLimit = 50;
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -120,8 +87,6 @@ internal sealed class HandoffAgentExecutor :
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private int _autonomousModeTurnCount;
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
@@ -312,38 +277,6 @@ internal sealed class HandoffAgentExecutor :
// happens if we have no outstanding requests.
if (!this.HasOutstandingRequests)
{
// In autonomous mode, if no handoff was requested and we haven't hit the turn limit, continue the agent's
// turn by injecting a synthetic user message instead of returning control to the user.
if (this._options.AutonomousMode && !result.IsHandoffRequested && this._autonomousModeTurnCount < this._options.AutonomousModeTurnLimit)
{
ChatMessage autonomousMessage = new(ChatRole.User, this._options.AutonomousModePrompt)
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
};
int autonomousBookmark = newConversationBookmark;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
autonomousBookmark = sharedState!.Conversation.AddMessage(autonomousMessage);
return new ValueTask();
},
context,
cancellationToken).ConfigureAwait(false);
// Increment only after successfully adding the autonomous message to shared state.
// This ensures the counter remains accurate if the state write throws an exception.
this._autonomousModeTurnCount++;
return await this.ContinueTurnAsync(
state with { ConversationBookmark = autonomousBookmark },
[autonomousMessage],
context,
cancellationToken,
skipAddIncoming: true).ConfigureAwait(false);
}
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
@@ -388,11 +321,6 @@ internal sealed class HandoffAgentExecutor :
state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark };
// Reset the autonomous turn counter at the start of each new HandoffState turn so that
// the limit is applied fresh for every incoming message, regardless of how the previous
// turn ended (e.g. outstanding external requests that prevented an earlier reset).
this._autonomousModeTurnCount = 0;
return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true)
.ConfigureAwait(false);
}
@@ -329,18 +329,40 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
// Ensure the inner enumerator is always disposed, even if the consumer breaks out early
// (e.g. ToolApprovalAgent does `yield break` after emitting an approval request). Without
// this, downstream decorators like PerServiceCallChatHistoryPersistingChatClient would be
// left suspended at `yield return`, never running their finally blocks, and any in-flight
// FunctionResultContent / FunctionCallContent state would not be persisted before the next
// turn, leaving the next request to the model with dangling tool calls.
bool hasUpdates;
try
{
bool hasUpdates;
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
}
try
{
// Ensure we start the streaming request
// Re-ensure the run context has the resolved session before each MoveNextAsync.
// The base class RunStreamingAsync restores the original context (potentially with
// null session) after each yield, so we must re-establish it for the decorator.
EnsureRunContextHasSession(safeSession);
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
@@ -348,55 +370,20 @@ public sealed partial class ChatClientAgent : AIAgent
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
}
try
{
// Re-ensure the run context has the resolved session before each MoveNextAsync.
// The base class RunStreamingAsync restores the original context (potentially with
// null session) after each yield, so we must re-establish it for the decorator.
EnsureRunContextHasSession(safeSession);
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
// When resuming from a continuation token or using background responses, force notification
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
finally
{
await responseUpdatesEnumerator.DisposeAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
// When resuming from a continuation token or using background responses, force notification
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -152,14 +152,7 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|| options?.AllowBackgroundResponses is true;
bool skipSimulation = isServiceManaged || isContinuationOrBackground;
// Snapshot the input messages into a private list. The caller (typically
// FunctionInvokingChatClient) reuses a single mutable buffer across iterations,
// and the streaming path can defer persistence until after the caller has already
// mutated that buffer for the next iteration (e.g. on the cooperative early-exit
// path NotifyProvidersOfEarlyExitInputAsync). Aliasing the caller's list would
// then cause us to persist the wrong messages — losing FunctionResultContent and
// corrupting history with dangling FunctionCallContent.
var newMessages = messages.ToList();
var newMessages = messages as IList<ChatMessage> ?? messages.ToList();
// When simulating, load history and prepend it. When the service manages
// history (real ConversationId) or this is a continuation/background run,
@@ -181,83 +174,45 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
throw;
}
bool loopExitedNormally = false;
bool serviceErrorOccurred = false;
bool hasUpdates;
try
{
bool hasUpdates;
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
serviceErrorOccurred = true;
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
serviceErrorOccurred = true;
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
}
loopExitedNormally = true;
}
finally
{
// If the iterator was disposed by the consumer before completing — e.g.
// ToolApprovalAgent does `yield break` after emitting an approval request — persist
// the input messages so that any in-flight FunctionResultContent paired with
// previously-persisted FunctionCallContent is not lost between turns. We only do
// this on the cooperative-pause path; service errors deliberately do NOT persist
// input messages (history of failed calls is the caller's responsibility, e.g.
// by retrying or starting from an earlier point).
if (!loopExitedNormally && !serviceErrorOccurred)
{
// Prefer the original cancellation token so cleanup remains responsive; fall
// back to None only if the caller's token has already been canceled (otherwise
// the notify call would observe the cancellation, throw, and mask the
// original early-exit reason).
var persistToken = cancellationToken.IsCancellationRequested ? CancellationToken.None : cancellationToken;
try
{
await NotifyProvidersOfEarlyExitInputAsync(agent, session, newMessages, options, persistToken).ConfigureAwait(false);
}
catch
{
// Best-effort persistence; swallow to avoid masking the original exit reason.
}
}
// Always dispose the underlying enumerator on every exit path (normal completion,
// exception, or early consumer disposal) to release the underlying HTTP/stream.
await enumerator.DisposeAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
@@ -281,30 +236,6 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
}
}
/// <summary>
/// Notifies <see cref="ChatHistoryProvider"/>s of the input messages only (no response
/// messages) on the cooperative early-exit path — e.g. when <c>ToolApprovalAgent</c>
/// does <c>yield break</c> after emitting an approval request. This ensures any
/// in-flight <see cref="FunctionResultContent"/> paired with previously-persisted
/// <see cref="FunctionCallContent"/> is not orphaned in the persisted chat history.
/// The notification is routed through the same success channel used at the end of a
/// normal run; the providers themselves decide how (or whether) to persist.
/// </summary>
private static async Task NotifyProvidersOfEarlyExitInputAsync(
ChatClientAgent agent,
ChatClientAgentSession session,
List<ChatMessage> newMessages,
ChatOptions? options,
CancellationToken cancellationToken)
{
if (newMessages.Count == 0)
{
return;
}
await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, [], options, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sets the sentinel <see cref="LocalHistoryConversationId"/> on the response and session
/// so that <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed.
@@ -21,10 +21,6 @@ internal static class TestSettings
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
// Azure AI Search (Foundry.Hosting integration tests, RAG scenario)
public const string AzureSearchEndpoint = "AZURE_SEARCH_ENDPOINT";
public const string AzureSearchIndexName = "AZURE_SEARCH_INDEX_NAME";
// Foundry Hosted Agents (Foundry.Hosting integration tests)
public const string FoundryHostingItImage = "IT_HOSTED_AGENT_IMAGE";
@@ -33,7 +33,6 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
@@ -1,11 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure;
using Azure.AI.Projects;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -32,10 +29,9 @@ AIAgent agent = scenario switch
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"toolbox" => CreateToolboxAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
@@ -83,6 +79,17 @@ static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string dep
AIFunctionFactory.Create(SendEmail)
]);
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
client.AsAIAgent(
model: deployment,
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
name: "toolbox-agent",
description: "Toolbox test agent (placeholder).",
tools: [
AIFunctionFactory.Create(GetEnvironmentName)
]);
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
client.AsAIAgent(
@@ -99,86 +106,6 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployment)
{
// The fixture (AzureSearchRagHostedAgentFixture) injects AZURE_SEARCH_ENDPOINT and
// AZURE_SEARCH_INDEX_NAME into the hosted agent definition. The index is provisioned
// out of band (see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md for the
// required schema and seed content); the container only needs read access. The
// agent's managed identity must hold 'Search Index Data Reader' on the search service
// scope.
var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set for IT_SCENARIO=azure-search-rag."));
var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-rag.");
var searchClient = new SearchClient(searchEndpoint, indexName, new DefaultAzureCredential());
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "azure-search-rag-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateAzureSearchAdapter(searchClient), options)]
});
}
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var searchOptions = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, searchOptions, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: """
You are a friendly assistant that helps users inspect and summarise
files stored in the session sandbox at $HOME.
Always answer file-related questions by calling the available tools
(GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or
contents read the file before answering.
Quote numbers and figures verbatim from the file rather than
paraphrasing them.
""",
name: "session-files-agent",
description: "Reads files from the per-session $HOME volume.",
tools: [
AIFunctionFactory.Create(GetHomeDirectory),
AIFunctionFactory.Create(ListFiles),
AIFunctionFactory.Create(ReadFile)
]);
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
@@ -191,73 +118,5 @@ static string SendEmail(
[Description("Email subject")] string subject) =>
$"Email sent to {to} with subject '{subject}'.";
// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
[Description("Get the absolute path of the session home directory ($HOME).")]
static string GetHomeDirectory() => SessionHome();
[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")]
static string[] ListFiles(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray();
}
catch (Exception ex)
{
return [$"Error listing '{path}': {ex.Message}"];
}
}
[Description("Read the full text contents of a file inside the session sandbox.")]
static string ReadFile(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return File.ReadAllText(ResolveSessionPath(path));
}
catch (Exception ex)
{
return $"Error reading '{path}': {ex.Message}";
}
}
static string SessionHome() =>
Environment.GetEnvironmentVariable("HOME")
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments
// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles
// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize +
// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath.
static string ResolveSessionPath(string path)
{
string home = SessionHome();
string homeFull = Path.GetFullPath(home);
string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar)
? homeFull
: homeFull + Path.DirectorySeparatorChar;
if (string.IsNullOrWhiteSpace(path))
{
return homeFull;
}
if (Path.IsPathRooted(path))
{
throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path));
}
string combined = Path.Combine(homeFull, path);
string fullPath = Path.GetFullPath(combined);
if (!fullPath.Equals(homeFull, StringComparison.Ordinal) &&
!fullPath.StartsWith(homePrefix, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Path '{path}' resolves outside the session sandbox.", nameof(path));
}
return fullPath;
}
[Description("Returns the deployment environment name.")]
static string GetEnvironmentName() => "integration-test";
@@ -1,79 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End to end RAG integration tests against a hosted agent backed by Azure AI Search.
/// The hosted agent runs the test container with <c>IT_SCENARIO=azure-search-rag</c>, which
/// wires <see cref="TextSearchProvider"/> over a real <c>SearchClient</c> against the
/// pre-seeded Contoso Outdoors index.
/// </summary>
/// <remarks>
/// Each test asks for a unique <c>*-CANARY-*</c> token that exists ONLY in the seeded
/// document. The model cannot fabricate these tokens from its training data, so a passing
/// assertion is proof the agent retrieved the seeded document via Azure AI Search rather
/// than answering from general knowledge.
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AzureSearchRagHostedAgentTests(AzureSearchRagHostedAgentFixture fixture)
: IClassFixture<AzureSearchRagHostedAgentFixture>
{
private readonly AzureSearchRagHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RagAnswer_CitesSeededReturnPolicy_WhenAskedAboutReturnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: ask about the canary SKU embedded in the seeded Return Policy doc. The
// canary token (TR-CANARY-7821) is unfakeable - it does not exist in any model
// training data, so its presence in the answer is proof the agent retrieved
// the seeded document via the Azure AI Search adapter.
var response = await agent.RunAsync(
"What item code do I get with my return? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("TR-CANARY-7821", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_CitesShippingGuide_WhenAskedAboutShippingAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: canary promo code (SHIP-CANARY-4493) is unique to the seeded Shipping
// Guide doc. Its presence proves the answer was grounded in retrieved content.
var response = await agent.RunAsync(
"What promo code can I use for free overnight shipping? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("SHIP-CANARY-4493", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_StaysGroundedWithoutContext_WhenAskedUnrelatedQuestionAsync()
{
// Arrange: ask something that is NOT covered by the three seeded Contoso documents.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync(
"What is the boiling point of liquid nitrogen in degrees Celsius? " +
"Just give the number with units, no other context.");
// Assert: response is non empty AND does NOT fabricate a Contoso source citation.
// The agent may either answer from its general knowledge or admit uncertainty; either
// is acceptable. The key assertion is that we do not see a fake Contoso link.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.DoesNotContain("contoso.com", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -1,41 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConformance.IntegrationTests.Support;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=azure-search-rag</c> mode.
/// Wires the container up with an Azure AI Search backed <see cref="Microsoft.Agents.AI.TextSearchProvider"/>
/// adapter that retrieves Contoso Outdoors documents from a pre-provisioned search index before each
/// model invocation.
/// </summary>
/// <remarks>
/// Prerequisites managed out of band:
/// <list type="bullet">
/// <item><description>The <c>it-azure-search-rag</c> agent's managed identity must hold
/// <c>Search Index Data Reader</c> on the search service scope. Granted manually after
/// the first <c>scripts/it-bootstrap-agents.ps1</c> run; see the IT README.</description></item>
/// <item><description>The search index referenced by <c>AZURE_SEARCH_INDEX_NAME</c> must
/// already exist with the documented schema and Contoso Outdoors content. The search
/// service is shared with <c>python-sample-validation.yml</c>; no .NET-side provisioning
/// script ships with this repository.</description></item>
/// </list>
/// </remarks>
public sealed class AzureSearchRagHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "azure-search-rag";
/// <summary>
/// Inject the AZURE_SEARCH_* env vars onto the hosted agent definition so the test container
/// scenario branch can construct its <c>SearchClient</c>. These names are NOT in the platform
/// reserved <c>FOUNDRY_*</c> / <c>AGENT_*</c> namespace so they are safe to set.
/// </summary>
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment[TestSettings.AzureSearchEndpoint] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchEndpoint);
environment[TestSettings.AzureSearchIndexName] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
}
}
@@ -1,17 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=session-files</c> mode.
/// The container exposes three local function tools (<c>GetHomeDirectory</c>, <c>ListFiles</c>,
/// <c>ReadFile</c>) that read from the per-session <c>$HOME</c> sandbox volume — mirroring the
/// <c>Hosted-Files</c> sample. Tests use the alpha
/// <see cref="Azure.AI.Projects.Agents.AgentSessionFiles"/> API to upload a file into the session
/// sandbox, then invoke the agent (pinned to the same <c>agent_session_id</c>) and assert that the
/// agent's tools observed the uploaded file.
/// </summary>
public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "session-files";
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
/// that the model can invoke those tools and that client side toolbox additions surface alongside
/// server side registrations when listed.
/// </summary>
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "toolbox";
}
@@ -20,17 +20,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<!-- Linked from the Hosted-Files sample so the demo testdata file has a single source of truth. -->
<Content Include="..\..\samples\04-hosting\FoundryHostedAgents\responses\Hosted-Files\resources\contoso_q1_2026_report.txt"
Link="TestData\contoso_q1_2026_report.txt"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -38,8 +38,6 @@ etc.).
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
| `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. |
| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. |
## One-time bootstrap (per Foundry project)
@@ -59,58 +57,6 @@ The script is idempotent. It requires Owner or User Access Administrator on the
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
running the tests.
### Per-scenario data-plane RBAC (manual, one time per agent)
The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what
every hosted agent needs to receive inbound inference traffic. Scenarios that read from
external data services need an additional grant on that service to the agent's managed
identity. Today only the `azure-search-rag` scenario falls into this category.
For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader`
on the Azure AI Search service to the agent's managed identity:
```powershell
# 1. Get the agent MI principal id
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
$agent = Invoke-RestMethod `
-Headers @{Authorization="Bearer $tok"; "Foundry-Features"="HostedAgents=V1Preview"} `
-Uri "<project-endpoint>/agents/it-azure-search-rag?api-version=v1"
$mi = $agent.versions.latest.instance_identity.principal_id
# 2. Grant Search Index Data Reader on the search service
az role assignment create `
--assignee-object-id $mi `
--assignee-principal-type ServicePrincipal `
--role "Search Index Data Reader" `
--scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Search/searchServices/<search-service>"
```
Wait ~3 minutes after the grant for RBAC propagation before running the tests.
If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra
auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first:
```powershell
az search service update -g <rg> -n <search-service> --auth-options aadOrApiKey --aad-auth-failure-mode http403
```
### Azure AI Search index prerequisite (one time, out of band)
The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already
exists with the schema and Contoso Outdoors content the test asserts against. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for
the schema and copy-pasteable provisioning snippet. Provisioning the index from your user
identity needs `Search Index Data Contributor` on the search service scope. The search service
itself is treated as pre-existing infrastructure shared with `python-sample-validation.yml`;
no automated provisioning script ships in this repository.
### Required user/SP roles for delegating data-plane grants
To self-serve the `Search Index Data Reader` grant above, you need `User Access Administrator`
(or `Owner`) on the search service scope. To create/seed the index from your own identity, you
need `Search Index Data Contributor`. These are typically granted once per onboarded engineer
and reused for every new IT scenario that needs Search.
## Building and pushing the test container image
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
@@ -169,8 +115,6 @@ container, the test fixture, or their tooling changed:
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
| `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) |
| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) |
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
@@ -181,10 +125,6 @@ The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
The Azure AI Search index referenced by `secrets.AZURE_SEARCH_ENDPOINT` and
`secrets.AZURE_SEARCH_INDEX_NAME` is provisioned out of band (shared with
`python-sample-validation.yml`); CI does not need write access to the search service.
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
human-only operation; CI only adds and deletes versions under existing agents.
@@ -195,10 +135,9 @@ human-only operation; CI only adds and deletes versions under existing agents.
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. |
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
The placeholder scenarios will be wired up in the test container `Program.cs` once the
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
@@ -1,238 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable AAIP001 // AgentSessionFiles is experimental
#pragma warning disable OPENAI001 // CreateResponseOptions is experimental
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
/// via the alpha <see cref="AgentSessionFiles"/> SDK is read by the deployed hosted agent's
/// container-side <c>ReadFile</c> tool and surfaces in <see cref="AIAgent.RunAsync(string, AgentSession, AgentRunOptions, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// <para>
/// Routing both invocations to the same per-session container requires two clients on the same
/// agent-scoped <see cref="ProjectOpenAIClient"/>: a <see cref="ProjectConversationsClient"/> to
/// pre-create a conversation bound to the agent endpoint, and a <see cref="ProjectResponsesClient"/>
/// for invocation. The session id resolved by the platform on the first call is captured from the
/// <c>x-agent-session-id</c> response header and used to target the
/// <see cref="AgentSessionFiles"/> upload at the same session's <c>$HOME</c>. The second call
/// carries the same conversation_id so it lands in the same container and the agent's
/// <c>ReadFile</c> tool sees the upload.
/// </para>
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture<SessionFilesHostedAgentFixture>
{
private const string FoundryFeaturesHeader = "Foundry-Features";
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
private const string SessionIdHeader = "x-agent-session-id";
private const string TestDataFileName = "contoso_q1_2026_report.txt";
/// <summary>Token that appears verbatim in the test data file. Proof the agent read what we uploaded.</summary>
private const string ExpectedTokenInFile = "1,482.6";
private readonly SessionFilesHostedAgentFixture _fixture = fixture;
[Fact]
public async Task UploadedFile_IsReadByHostedAgentAsync()
{
// Arrange
string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName);
Assert.True(
File.Exists(localPath),
$"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj.");
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
// Admin client + AgentSessionFiles for upload/list/delete (alpha SDK).
var adminOptions = new AgentAdministrationClientOptions();
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
var sessionFiles = adminClient.GetAgentSessionFiles();
// Build the per-agent OpenAI client. The conversation is created on this client so it is
// bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`).
// A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply.
var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader);
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName };
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall);
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
var conversations = openAIClient.GetProjectConversationsClient();
var responses = openAIClient.GetProjectResponsesClient();
// Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls
// tagged with this conversation_id route to the same per-session container.
var conversation = await conversations.CreateProjectConversationAsync();
string conversationId = conversation.Value.Id;
try
{
// Step 2 — warm-up call. Provisions the per-session container under the conversation and
// lets us read back the resolved agent_session_id from the response header.
var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName);
var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
var warmup = await agent.RunAsync(
"Reply with the single word 'ready' and nothing else.",
options: convOptions);
Assert.False(string.IsNullOrWhiteSpace(warmup.Text));
string agentSessionId = headerCapture.LastValue
?? throw new InvalidOperationException(
$"Expected '{SessionIdHeader}' response header on warm-up but got none.");
try
{
// Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
SessionFileWriteResponse writeResponse = await sessionFiles.UploadSessionFileAsync(
agentName: this._fixture.AgentName,
sessionId: agentSessionId,
sessionStoragePath: TestDataFileName,
localPath: localPath);
long expectedBytes = new FileInfo(localPath).Length;
Assert.Equal(expectedBytes, writeResponse.BytesWritten);
SessionDirectoryListResponse listing = await sessionFiles.GetSessionFilesAsync(
agentName: this._fixture.AgentName,
sessionId: agentSessionId,
sessionStoragePath: ".");
Assert.Contains(
listing.Entries,
e => e.Name == TestDataFileName && !e.IsDirectory && e.Size == expectedBytes);
// Step 4 — invoke the agent again on the SAME conversation. The platform routes back to
// the same agent_session_id container, so the agent's ReadFile tool sees the upload.
// The platform mutates session/conversation revision when AgentSessionFiles uploads land,
// so an immediate /responses follow-up races and 400's with "modified concurrently. Please
// retry." — the response message literally tells us to retry. Bounded retry handles it.
var readOptions = new CreateResponseOptions { AgentConversationId = conversationId };
readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(
$"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary."));
ClientResult<ResponseResult> rawResponse = null!;
const int MaxAttempts = 5;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
rawResponse = await responses.CreateResponseAsync(readOptions);
break;
}
catch (ClientResultException ex) when (
ex.Status == 400 &&
ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) &&
attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(2 * attempt));
}
}
string responseText = rawResponse.Value.GetOutputText() ?? string.Empty;
Assert.Equal(agentSessionId, headerCapture.LastValue);
// Assert: the response contains the deterministic token from the file.
Assert.False(string.IsNullOrWhiteSpace(responseText));
Assert.Contains(ExpectedTokenInFile, responseText);
}
finally
{
// Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry —
// the platform owns its lifecycle (no isolation key in our hands).
try
{
await sessionFiles.DeleteSessionFileAsync(
agentName: this._fixture.AgentName,
sessionId: agentSessionId,
path: TestDataFileName);
}
catch
{
// Ignore.
}
}
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
/// <summary>
/// Captures a response header value on every pipeline call. Latest value is read after the
/// response completes. Used to grab the platform's <c>x-agent-session-id</c> stamp.
/// </summary>
private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy
{
private readonly string _headerName = headerName;
private string? _lastValue;
public string? LastValue => Volatile.Read(ref this._lastValue);
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
this.Capture(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
this.Capture(message);
}
private void Capture(PipelineMessage message)
{
if (message.Response is not null &&
message.Response.Headers.TryGetValue(this._headerName, out var value) &&
!string.IsNullOrEmpty(value))
{
Volatile.Write(ref this._lastValue, value);
}
}
}
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private void SetHeader(PipelineMessage message)
{
message.Request.Headers.Remove(FoundryFeaturesHeader);
message.Request.Headers.Add(FoundryFeaturesHeader, features);
}
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for the Foundry toolbox: the hosted container registers tools via the toolbox API
/// (server side), and tests can also add tools client side. The model should be able to
/// invoke tools from both sources.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolboxHostedAgentTests(ToolboxHostedAgentFixture fixture) : IClassFixture<ToolboxHostedAgentFixture>
{
private readonly ToolboxHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ServerRegisteredToolboxTool_IsCallableAsync()
{
// Arrange: the container side toolbox registers GetEnvironmentName which returns a constant.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Call GetEnvironmentName via the toolbox and reply with just the value.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("integration-test", response.Text, System.StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ClientSideAddedToolboxTool_IsListedAndCallableAsync()
{
// TODO: requires AgentToolboxes API surface. Placeholder asserting the test runs.
var agent = this._fixture.Agent;
var response = await agent.RunAsync("List all tools you have access to.");
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ListingTools_ReturnsBothServerAndClientSideEntriesAsync()
{
// TODO: requires AgentAdministrationClient toolbox listing. Placeholder.
var agent = this._fixture.Agent;
var response = await agent.RunAsync("Briefly describe what tools are available.");
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
}
@@ -20,13 +20,6 @@
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
Use the value emitted by scripts/it-build-image.ps1.
.NOTES
Per-scenario data-plane RBAC (e.g. `Search Index Data Reader` on the Azure AI Search service
for the `azure-search-rag` scenario) is intentionally NOT performed by this script. Search,
Cosmos, and other backing services are treated as pre-existing infrastructure. Grant the
scenario-specific data role to the agent's managed identity manually after the first run
(see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md).
.EXAMPLE
./it-bootstrap-agents.ps1 `
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
@@ -43,10 +36,9 @@ $Scenarios = @(
'happy-path',
'tool-calling',
'tool-calling-approval',
'toolbox',
'mcp-toolbox',
'custom-storage',
'azure-search-rag',
'session-files'
'custom-storage'
)
# Resolve project ARM scope from the endpoint.
@@ -41,7 +41,14 @@ param(
[string] $Repository = "foundry-hosting-it",
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer",
# Explicit opt-in for the no-rebuild fast path. CI sets this after running the
# "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt
# library DLLs match current source. Off by default so local invocations always
# let publish rebuild ProjectReferences and never produce an image whose tag is
# computed from current source while the contents come from a stale build.
[switch] $UsePrebuiltProjectReferences
)
$ErrorActionPreference = "Stop"
@@ -100,35 +107,60 @@ if (Test-Path $out) {
Remove-Item -Recurse -Force $out
}
# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish
# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their
# transitive deps) by reading the prebuilt DLLs at src/<lib>/bin/Release/net10.0/*.dll.
# This:
# 1) Structurally avoids the MSB3026 "file is being used by another process" race that
# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced
# while VBCSCompiler from that build still holds file handles.
# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs.
# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release`
# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the
# preceding "Build Foundry hosted IT (and its deps)" step.
$prebuildProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll",
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll"
)
$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) })
if ($missingPrebuilds.Count -gt 0) {
$msg = @(
"Required prebuilt outputs not found:"
($missingPrebuilds | ForEach-Object { " - $_" })
""
"Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the"
"test project first so its ProjectReference closure populates src/<lib>/bin/Release/net10.0/:"
" dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release"
) -join "`n"
throw $msg
# Conditionally tell publish to skip rebuilding ProjectReferences and consume the
# prebuilt library DLLs in place. This avoids two failure modes that arise when
# the CI workflow runs a `dotnet build` of the same library projects immediately
# before this script:
# 1) MSB3026 "file is being used by another process" when publish's MSBuild
# tries to overwrite src/<lib>/bin/Release/net10.0/<lib>.dll while the
# previous build's shared-compilation server still holds a file handle.
# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library
# DLLs that prebuild already produced.
# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker
# detection, because a developer machine may have a stale Release build of the
# libraries from days ago; using those would silently produce an image whose
# content is older than the source the tag is computed from.
$publishExtraArgs = @()
if ($UsePrebuiltProjectReferences) {
Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray
$publishExtraArgs += "-p:BuildProjectReferences=false"
} else {
# Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64
# to library ProjectReferences and writes their intermediates to a RID-suffixed obj path
# (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new
# IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a
# prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile
# glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and
# tell the user exactly how to recover.
$staleObjProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0"
)
$stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") })
if ($stale.Count -gt 0) {
$msg = @(
"Detected prior Release/net10.0 build outputs in:"
($stale | ForEach-Object { " - $_" })
""
"Publish would propagate -r linux-musl-x64 to those ProjectReferences and the"
"leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate"
"attribute errors. Pick one:"
" (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and"
" uses the existing src/<lib>/bin/Release/net10.0/*.dll outputs in place)."
" Only safe when you know those DLLs match current source - this is the path"
" CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step."
" (b) Remove the stale obj/Release trees, e.g.:"
" Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release"
" and re-run."
) -join "`n"
throw $msg
}
Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray
}
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
@@ -1,183 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.DevUI.UnitTests;
public class DevUIAccessControlTests
{
private static WebApplicationBuilder NewBuilder()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton<AIAgent>("agent-name", agent);
return builder;
}
private static void SimulateRemoteIp(WebApplication app, IPAddress remoteIp)
{
app.Use(async (HttpContext ctx, RequestDelegate next) =>
{
ctx.Connection.RemoteIpAddress = remoteIp;
await next(ctx);
});
}
[Fact]
public async Task NonLoopbackRequest_ReturnsForbiddenByDefaultAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI();
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task NonLoopbackRequest_IsAllowedWhenAllowRemoteAccessAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task LoopbackRequest_WithAuthTokenSet_RequiresBearerHeaderAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token");
var response = await app.GetTestClient().SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredAsync()
{
const string EnvVar = "DEVUI_AUTH_TOKEN";
const string EnvToken = "env-token";
var previous = Environment.GetEnvironmentVariable(EnvVar);
Environment.SetEnvironmentVariable(EnvVar, EnvToken);
WebApplication? app = null;
try
{
var builder = NewBuilder();
builder.Services.AddDevUI();
app = builder.Build();
// Force singleton construction so the env var is captured before we
// restore it; otherwise tests running in parallel can pick up the
// leaked DEVUI_AUTH_TOKEN.
_ = app.Services.GetRequiredService<DevUIAuthFilter>();
}
finally
{
Environment.SetEnvironmentVariable(EnvVar, previous);
}
await using (app)
{
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
var missing = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode);
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", EnvToken);
var accepted = await app.GetTestClient().SendAsync(request);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
}
}
[Fact]
public async Task MetaEndpoint_IsReachableWithoutAuthenticationAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
var response = await app.GetTestClient().GetAsync(new Uri("/meta", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("\"auth_required\":true", body);
}
[Fact]
public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync()
{
var builder = NewBuilder();
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
using var app = builder.Build();
SimulateRemoteIp(app, IPAddress.Loopback);
app.MapDevUI();
await app.StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-the-token");
var response = await app.GetTestClient().SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -33,7 +33,7 @@ public class DevUIIntegrationTests
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton<AIAgent>("registration-key", agent);
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -66,7 +66,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agent1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agent2);
builder.Services.AddKeyedSingleton<AIAgent>("key-3", agent3);
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -102,7 +102,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agentKeyed1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agentKeyed2);
builder.Services.AddSingleton<AIAgent>(agentDefault);
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -151,7 +151,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton("key-1", workflow1);
builder.Services.AddKeyedSingleton("key-2", workflow2);
builder.Services.AddKeyedSingleton("key-3", workflow3);
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -197,7 +197,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton("key-1", workflowKeyed1);
builder.Services.AddKeyedSingleton("key-2", workflowKeyed2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -255,7 +255,7 @@ public class DevUIIntegrationTests
builder.Services.AddKeyedSingleton("workflow-key-1", workflow1);
builder.Services.AddKeyedSingleton("workflow-key-2", workflow2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
@@ -0,0 +1,328 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryToolbox"/> class.
/// </summary>
public class FoundryToolboxTests
{
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
#region Parameter validation tests
[Fact]
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: null!,
name: "test-toolbox"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
{
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
name: name!));
}
[Fact]
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolsAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public void ToAITools_NullToolboxVersion_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
FoundryToolbox.ToAITools(null!));
}
#endregion
#region ToAITools conversion tests
[Fact]
public void ToAITools_EmptyTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "empty-toolbox",
version: "v1",
description: "Empty",
createdAt: DateTimeOffset.UtcNow,
tools: Array.Empty<ProjectsAgentTool>(),
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_NullTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "null-tools-toolbox",
version: "v1",
description: "Null tools",
createdAt: DateTimeOffset.UtcNow,
tools: null,
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
{
var json = TestDataUtil.GetToolboxVersionResponseJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
{
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
{
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
Assert.IsAssignableFrom<AITool>(aiTool);
}
[Fact]
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
{
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
}
#endregion
#region Integration tests with mock HTTP
[Fact]
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((request) =>
{
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
Assert.Single(result.Tools);
}
[Fact]
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
{
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
var callCount = 0;
using var httpHandler = new HttpHandlerAssert((request) =>
{
callCount++;
var path = request.RequestUri!.PathAndQuery;
if (!path.Contains("/versions/"))
{
Assert.Contains("/toolboxes/research_tools", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
};
}
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: null,
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal(2, callCount);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
}
[Fact]
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
{
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
await Assert.ThrowsAsync<ClientResultException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"nonexistent-toolbox",
version: "v1",
clientOptions: clientOptions,
cancellationToken: default));
}
[Fact]
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
var tools = result.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
#region AIProjectClient extension tests
[Fact]
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AIProjectClientOptions();
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
internal sealed class HttpHandlerAssert : HttpClientHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
{
this._assertion = assertion;
}
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
{
this._assertionAsync = assertionAsync;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (this._assertionAsync is not null)
{
return await this._assertionAsync.Invoke(request);
}
return this._assertion!.Invoke(request);
}
#if NET
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
return this._assertion!(request);
}
#endif
}
@@ -20,4 +20,16 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\ToolboxRecordResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
{
"id": "tbx-123",
"name": "research_tools",
"default_version": "v5"
}
@@ -0,0 +1,11 @@
{
"metadata": {},
"id": "tbv-research_tools-v5",
"name": "research_tools",
"version": "v5",
"description": "Example research toolbox",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter" }
]
}
@@ -0,0 +1,11 @@
{
"metadata": {},
"id": "tbv-dirty-v1",
"name": "dirty_toolbox",
"version": "v1",
"description": "Toolbox with decoration fields on tools",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
]
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.IO;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Utility class for loading toolbox-related test data files.
/// </summary>
internal static class TestDataUtil
{
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
/// <summary>
/// Gets the toolbox record response JSON.
/// </summary>
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
/// <summary>
/// Gets the toolbox version response JSON.
/// </summary>
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
/// <summary>
/// Gets the toolbox version response JSON with decoration fields on tools.
/// </summary>
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
}
@@ -245,33 +245,29 @@ public sealed class ClientHeadersExtensionsTests
}
// -------------------------------------------------------------------------------------------
// 10. ClientHeadersScope is AsyncLocal-isolated across parallel runs and auto-restores on
// async-method return (no explicit Dispose needed).
// 10. ClientHeadersScope.Push is LIFO and AsyncLocal-isolated (parallel runs don't leak)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersScope_IsAsyncLocalIsolatedAndAutoRestoresAsync()
public async Task ClientHeadersScope_IsLifoAndAsyncLocalIsolatedAsync()
{
// Arrange
var dictA = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
// Act / Assert: parallel async flows do not see each other's mutations.
// Act / Assert
await Task.WhenAll(
ProbeAsync(dictA, "alice"),
ProbeAsync(dictB, "bob"));
async Task ProbeAsync(Dictionary<string, string> dict, string expected)
{
ClientHeadersScope.Current = dict;
await Task.Yield();
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
using (ClientHeadersScope.Push(dict))
{
await Task.Yield();
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
}
}
// Assert: setting Current inside an awaited async method does not leak back to the caller
// after the method returns. This is the AsyncLocal natural-restoration behavior the
// ClientHeadersAgent relies on.
Assert.Null(ClientHeadersScope.Current);
}
// -------------------------------------------------------------------------------------------
@@ -324,19 +320,16 @@ public sealed class ClientHeadersExtensionsTests
perTryPolicies: default,
beforeTransportPolicies: default);
var perCall = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
// Act
ClientHeadersScope.Current = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
try
using (ClientHeadersScope.Push(perCall))
{
var msg = pipeline.CreateMessage();
msg.Request.Method = "GET";
msg.Request.Uri = new Uri("https://example.test/");
await pipeline.SendAsync(msg);
}
finally
{
ClientHeadersScope.Current = null;
}
// Assert: the per-call value won.
Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
@@ -1311,64 +1311,4 @@ public class PerServiceCallChatHistoryPersistingChatClientTests
// Assert — session should NOT have the sentinel
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that when the consumer abandons enumeration early (the streaming enumerator is
/// disposed before completing — e.g. <c>ToolApprovalAgent.RunStreamingAsync</c> doing a
/// <c>yield break</c>), the decorator still persists the input messages via its <c>finally</c>
/// block. This regression-guards the dropped-FunctionResultContent → HTTP 400 bug.
/// </summary>
[Fact]
public async Task RunStreamingAsync_PersistsInputMessages_WhenConsumerAbandonsEnumerationAsync()
{
// Arrange — emit multiple updates so the consumer can break after the first.
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "first "),
new ChatResponseUpdate(ChatRole.Assistant, "second "),
new ChatResponseUpdate(ChatRole.Assistant, "third")));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act — consumer breaks out after the first update, mirroring ToolApprovalAgent's
// yield-break-on-approval-required path.
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "frc-input")], session))
{
break;
}
// Assert — even though the consumer abandoned the stream, the input messages
// must still have been persisted (so we don't lose function-call/function-result
// pairings).
mockChatHistoryProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
x.RequestMessages.Any(m => m.Text == "frc-input") &&
(x.ResponseMessages == null || !x.ResponseMessages.Any()) &&
x.InvokeException == null),
ItExpr.IsAny<CancellationToken>());
}
}
@@ -201,189 +201,6 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
Func<Task> runStreamingAsync = async () => await executor.HandleAsync(state, testContext);
await runStreamingAsync.Should().NotThrowAsync();
}
[Fact]
public async Task Test_HandoffAgentExecutor_AutonomousMode_Disabled_DoesNotContinueWithoutHandoff()
{
// Arrange: agent with 3 prepared turns; autonomous mode OFF
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
TestReplayAgent agent = new(
[
TestReplayAgent.ToChatMessages("Turn 0 response"),
TestReplayAgent.ToChatMessages("Turn 1 response"),
TestReplayAgent.ToChatMessages("Turn 2 response"),
], TestAgentId, TestAgentName);
HandoffAgentExecutorOptions options = new("",
emitAgentResponseEvents: false,
emitAgentResponseUpdateEvents: false,
HandoffToolCallFilteringBehavior.None,
autonomousMode: false);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act
HandoffState message = new(new(false), null);
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
// Assert: without autonomous mode, the agent is called exactly once
agent.Turn.Should().Be(1);
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
.Which.Message.Should().BeOfType<HandoffState>()
.Subject;
sentState.RequestedHandoffTargetAgentId.Should().BeNull();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public async Task Test_HandoffAgentExecutor_AutonomousMode_InvokesAgentExactlyOnePlusTurnLimitTimes(int turnLimit)
{
// Arrange: agent with many prepared turns; no handoff ever requested; autonomous mode ON
// We prepare (turnLimit + 2) turns to detect off-by-one errors. TestReplayAgent stops
// incrementing Turn when prepared messages are exhausted, so preparing exactly (turnLimit + 1)
// turns would fail to detect if the implementation invokes the agent one extra time.
int totalTurns = turnLimit + 2;
TestReplayAgent agent = new(
Enumerable.Range(0, totalTurns)
.Select(i => TestReplayAgent.ToChatMessages($"Turn {i} response"))
.ToList(),
TestAgentId, TestAgentName);
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
HandoffAgentExecutorOptions options = new("",
emitAgentResponseEvents: false,
emitAgentResponseUpdateEvents: false,
HandoffToolCallFilteringBehavior.None,
autonomousMode: true,
autonomousModeTurnLimit: turnLimit);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act
HandoffState message = new(new(false), null);
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
// Assert: agent is called once for the initial turn plus once per autonomous turn
int expectedInvocations = 1 + turnLimit;
agent.Turn.Should().Be(expectedInvocations);
// The final HandoffState should have no requested handoff (turn limit exhausted)
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
.Which.Message.Should().BeOfType<HandoffState>()
.Subject;
sentState.RequestedHandoffTargetAgentId.Should().BeNull();
}
[Fact]
public async Task Test_HandoffAgentExecutor_AutonomousMode_HandoffDuringAutonomousTurn_RoutesToTarget()
{
// Arrange: agent returns a plain response on turn 0, then a handoff on turn 1 (the first autonomous turn)
TestEchoAgent targetAgent = new("target-agent", "Target Agent");
string handoffFunctionName = $"{HandoffWorkflowBuilder.FunctionPrefix}1"; // first (only) handoff target
string handoffCallId = Guid.NewGuid().ToString("N");
List<List<ChatMessage>> agentTurns =
[
TestReplayAgent.ToChatMessages("Initial response — no handoff yet"),
[new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(handoffCallId, handoffFunctionName)])
{
MessageId = Guid.NewGuid().ToString("N"),
}],
];
TestReplayAgent agent = new(agentTurns, TestAgentId, TestAgentName);
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
HandoffTarget handoffTarget = new(targetAgent);
HandoffAgentExecutorOptions options = new("",
emitAgentResponseEvents: false,
emitAgentResponseUpdateEvents: false,
HandoffToolCallFilteringBehavior.None,
autonomousMode: true,
autonomousModeTurnLimit: 5);
HandoffAgentExecutor executor = new(agent, [handoffTarget], options);
testContext.ConfigureExecutor(executor);
// Act
HandoffState message = new(new(false), null);
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
// Assert: agent was called twice (initial + 1 autonomous turn that triggered handoff)
agent.Turn.Should().Be(2);
// The final HandoffState should name the target agent
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
.Which.Message.Should().BeOfType<HandoffState>()
.Subject;
sentState.RequestedHandoffTargetAgentId.Should().Be(targetAgent.Id);
}
[Fact]
public async Task Test_HandoffAgentExecutor_AutonomousMode_AddsAutonomousPromptToConversation()
{
// Arrange: one turn without handoff, turn limit = 1 → one autonomous invocation
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
TestReplayAgent agent = new(
[
TestReplayAgent.ToChatMessages("First response"),
TestReplayAgent.ToChatMessages("Second response (autonomous)"),
], TestAgentId, TestAgentName);
const string CustomPrompt = "Continue your work autonomously.";
HandoffAgentExecutorOptions options = new("",
emitAgentResponseEvents: false,
emitAgentResponseUpdateEvents: false,
HandoffToolCallFilteringBehavior.None,
autonomousMode: true,
autonomousModePrompt: CustomPrompt,
autonomousModeTurnLimit: 1);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act
HandoffState message = new(new(false), null);
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
// Assert: the autonomous prompt was added to the shared conversation as a user message
HandoffSharedState? sharedState = await testContext
.BindWorkflowContext(nameof(HandoffStartExecutor))
.ReadStateAsync<HandoffSharedState>(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
sharedState.Should().NotBeNull();
sharedState!.Conversation.History.Should().Contain(
m => m.Role == ChatRole.User && m.Text == CustomPrompt,
because: "the autonomous mode prompt should be injected as a user message");
}
[Fact]
public async Task Test_HandoffWorkflowBuilder_EnableAutonomousMode_SetsOptionsOnExecutors()
{
// Arrange
TestEchoAgent initialAgent = new("initial", "Initial");
TestEchoAgent targetAgent = new("target", "Target");
// Act build a workflow with autonomous mode enabled and verify no exception is thrown
Workflow workflow = new HandoffWorkflowBuilder(initialAgent)
.WithHandoff(initialAgent, targetAgent)
.EnableAutonomousMode(prompt: "Keep going.", turnLimit: 10)
.Build();
// Assert: the workflow was built without error and contains the expected executors
workflow.Should().NotBeNull();
workflow.ExecutorBindings.Should().ContainKey(HandoffAgentExecutor.IdFor(initialAgent));
workflow.ExecutorBindings.Should().ContainKey(HandoffAgentExecutor.IdFor(targetAgent));
}
}
internal sealed record Challenge(string Value);
+6 -11
View File
@@ -23,28 +23,23 @@ response = await a2a_agent.run("Hello!")
```python
from agent_framework.a2a import A2AExecutor
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from starlette.applications import Starlette
# Create an A2A executor for your agent
executor = A2AExecutor(agent=my_agent)
# Set up the request handler (agent_card is required)
# Set up the request handler and server application
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
agent_card=my_agent_card,
)
# Build a Starlette app with A2A routes
app = Starlette(
routes=[
*create_agent_card_routes(my_agent_card),
*create_jsonrpc_routes(request_handler),
]
)
app = A2AStarletteApplication(
agent_card=my_agent_card,
http_handler=request_handler,
).build()
```
## Import Path
@@ -1,17 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import logging
from asyncio import CancelledError
from collections.abc import Mapping
from functools import partial
from typing import Any
from a2a.helpers import new_task_from_user_message
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import Part, TaskState
from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart
from a2a.utils import new_task
from agent_framework import (
AgentResponseUpdate,
AgentSession,
@@ -40,24 +39,21 @@ class A2AExecutor(AgentExecutor):
Example:
.. code-block:: python
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_jsonrpc_routes, create_agent_card_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentInterface
from a2a.types import AgentCapabilities, AgentCard
from agent_framework.a2a import A2AExecutor
from agent_framework.openai import OpenAIResponsesClient
from starlette.applications import Starlette
public_agent_card = AgentCard(
name="Food Agent",
description="A simple agent that provides food-related information.",
url="http://localhost:9999/",
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC"),
],
skills=[],
)
@@ -72,15 +68,12 @@ class A2AExecutor(AgentExecutor):
request_handler = DefaultRequestHandler(
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
task_store=InMemoryTaskStore(),
agent_card=public_agent_card,
)
app = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
],
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
).build()
Args:
agent: The AI agent to execute.
@@ -150,7 +143,7 @@ class A2AExecutor(AgentExecutor):
task = context.current_task
if not task:
task = new_task_from_user_message(context.message)
task = new_task(context.message)
await event_queue.enqueue_event(task)
updater = TaskUpdater(event_queue, task.id, context.context_id)
@@ -169,12 +162,13 @@ class A2AExecutor(AgentExecutor):
# Mark as complete
await updater.complete()
except CancelledError:
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
await updater.update_status(state=TaskState.canceled, final=True)
except Exception as e:
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
await updater.update_status(
state=TaskState.TASK_STATE_FAILED,
message=updater.new_agent_message([Part(text=str(e))]),
state=TaskState.failed,
final=True,
message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]),
)
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
@@ -227,9 +221,9 @@ class A2AExecutor(AgentExecutor):
) -> None:
# Custom logic to transform item contents
if item.role == "assistant" and item.contents:
parts = [Part(text=f"Custom: {item.contents[0].text}")]
parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))]
await updater.update_status(
state=TaskState.TASK_STATE_WORKING,
state=TaskState.working,
message=updater.new_agent_message(parts=parts),
)
else:
@@ -248,12 +242,12 @@ class A2AExecutor(AgentExecutor):
for content in contents:
if content.type == "text" and content.text:
parts.append(Part(text=content.text))
parts.append(Part(root=TextPart(text=content.text)))
elif content.type == "data" and content.uri:
base64_str = get_uri_data(content.uri)
parts.append(Part(raw=base64.b64decode(base64_str), media_type=content.media_type or ""))
parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type))))
elif content.type == "uri" and content.uri:
parts.append(Part(url=content.uri, media_type=content.media_type or ""))
parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type))))
else:
# Silently skip unsupported content types
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
@@ -276,6 +270,6 @@ class A2AExecutor(AgentExecutor):
else:
# For final messages, we send TaskStatusUpdateEvent with 'working' state
await updater.update_status(
state=TaskState.TASK_STATE_WORKING,
state=TaskState.working,
message=updater.new_agent_message(parts=parts, metadata=metadata),
)
+132 -130
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
import json
import uuid
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, Final, Literal, TypeAlias, overload
@@ -13,14 +14,17 @@ from a2a.client.auth.interceptor import AuthInterceptor
from a2a.types import (
AgentCard,
Artifact,
GetTaskRequest,
SendMessageRequest,
StreamResponse,
SubscribeToTaskRequest,
FilePart,
FileWithBytes,
FileWithUri,
Task,
TaskArtifactUpdateEvent,
TaskIdParams,
TaskQueryParams,
TaskState,
TaskStatusUpdateEvent,
TextPart,
TransportProtocol,
)
from a2a.types import Message as A2AMessage
from a2a.types import Part as A2APart
@@ -41,7 +45,6 @@ from agent_framework import (
)
from agent_framework._types import AgentRunInputs
from agent_framework.observability import AgentTelemetryLayer
from google.protobuf.json_format import MessageToDict
__all__ = ["A2AAgent", "A2AContinuationToken"]
@@ -58,19 +61,20 @@ class A2AContinuationToken(ContinuationToken):
TERMINAL_TASK_STATES = [
TaskState.TASK_STATE_COMPLETED,
TaskState.TASK_STATE_FAILED,
TaskState.TASK_STATE_CANCELED,
TaskState.TASK_STATE_REJECTED,
TaskState.completed,
TaskState.failed,
TaskState.canceled,
TaskState.rejected,
]
IN_PROGRESS_TASK_STATES = [
TaskState.TASK_STATE_SUBMITTED,
TaskState.TASK_STATE_WORKING,
TaskState.TASK_STATE_INPUT_REQUIRED,
TaskState.TASK_STATE_AUTH_REQUIRED,
TaskState.submitted,
TaskState.working,
TaskState.input_required,
TaskState.auth_required,
]
A2AStreamItem: TypeAlias = StreamResponse
A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None]
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
class A2AAgent(AgentTelemetryLayer, BaseAgent):
@@ -135,7 +139,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
if url is None:
raise ValueError("Either agent_card or url must be provided")
# Create minimal agent card from URL
agent_card = minimal_agent_card(url, ["JSONRPC"])
agent_card = minimal_agent_card(url, [TransportProtocol.jsonrpc])
# Create or use provided httpx client
if http_client is None:
@@ -147,7 +151,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
# Create A2A client using factory
config = ClientConfig(
httpx_client=http_client,
supported_protocol_bindings=["JSONRPC"],
supported_transports=[TransportProtocol.jsonrpc],
)
factory = ClientFactory(config)
interceptors = [auth_interceptor] if auth_interceptor is not None else None
@@ -157,16 +161,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
except Exception as transport_error:
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
fallback_url = (
agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
)
if not fallback_url:
raise ValueError(
"A2A transport negotiation failed and no fallback URL is available. "
"Provide a 'url' argument or ensure 'agent_card.supported_interfaces' "
"contains at least one interface with a URL."
) from transport_error
fallback_card = minimal_agent_card(fallback_url, ["JSONRPC"])
fallback_card = minimal_agent_card(agent_card.url, [TransportProtocol.jsonrpc])
try:
self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore
except Exception as fallback_error:
@@ -285,8 +280,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
normalized_messages = normalize_messages(messages)
if continuation_token is not None:
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.subscribe(
SubscribeToTaskRequest(id=continuation_token["task_id"])
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
TaskIdParams(id=continuation_token["task_id"])
)
else:
if not normalized_messages:
@@ -295,7 +290,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
normalized_messages[-1],
context_id=session.service_session_id if session else None,
)
a2a_stream = self.client.send_message(SendMessageRequest(message=a2a_message))
a2a_stream = self.client.send_message(a2a_message)
provider_session = session
if provider_session is None and self.context_providers:
@@ -366,54 +361,38 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
all_updates: list[AgentResponseUpdate] = []
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
async for item in a2a_stream:
payload_type = item.WhichOneof("payload")
if payload_type == "message":
if isinstance(item, A2AMessage):
# Process A2A Message
msg = item.message
contents = self._parse_contents_from_a2a(msg.parts)
metadata = MessageToDict(msg.metadata) if msg.metadata else None
contents = self._parse_contents_from_a2a(item.parts)
update = AgentResponseUpdate(
contents=contents,
role="assistant" if msg.role == A2ARole.ROLE_AGENT else "user",
response_id=msg.message_id or str(uuid.uuid4()),
additional_properties={"a2a_metadata": metadata} if metadata else None,
raw_representation=msg,
role="assistant" if item.role == A2ARole.agent else "user",
response_id=str(getattr(item, "message_id", uuid.uuid4())),
additional_properties={"a2a_metadata": item.metadata} if item.metadata else None,
raw_representation=item,
)
all_updates.append(update)
yield update
elif payload_type == "task":
task = item.task
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
task, update_event = item
updates = self._updates_from_task(
task,
update_event=update_event,
background=background,
emit_intermediate=emit_intermediate,
streamed_artifact_ids=streamed_artifact_ids_by_task.get(task.id),
)
if isinstance(update_event, TaskArtifactUpdateEvent) and any(
update.raw_representation is update_event for update in updates
):
streamed_artifact_ids_by_task.setdefault(task.id, set()).add(update_event.artifact.artifact_id)
if task.status.state in TERMINAL_TASK_STATES:
streamed_artifact_ids_by_task.pop(task.id, None)
for update in updates:
all_updates.append(update)
yield update
elif payload_type == "status_update":
status_event = item.status_update
updates = self._updates_from_task_update_event(status_event)
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
elif payload_type == "artifact_update":
artifact_event = item.artifact_update
updates = self._updates_from_task_update_event(artifact_event)
if updates:
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
artifact_event.artifact.artifact_id
)
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
else:
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
raise NotImplementedError("Only Message and Task responses are supported")
# Set the response on the context for after_run providers
if all_updates:
@@ -429,6 +408,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
self,
task: Task,
*,
update_event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None,
background: bool = False,
emit_intermediate: bool = False,
streamed_artifact_ids: set[str] | None = None,
@@ -444,11 +424,17 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
completion.
"""
status = task.status
task_metadata = MessageToDict(task.metadata) if task.metadata else None
if (
emit_intermediate
and update_event is not None
and (event_updates := self._updates_from_task_update_event(update_event))
):
return event_updates
if status.state in TERMINAL_TASK_STATES:
task_messages = self._parse_messages_from_task(task)
if task.artifacts and streamed_artifact_ids:
if task.artifacts is not None and streamed_artifact_ids:
task_messages = [
message
for message in task_messages
@@ -462,20 +448,20 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
response_id=task.id,
message_id=getattr(message.raw_representation, "artifact_id", None),
additional_properties={"a2a_metadata": merged}
if (merged := {**message.additional_properties, **(task_metadata or {})})
if (merged := {**message.additional_properties, **(task.metadata or {})})
else None,
raw_representation=task,
)
for message in task_messages
]
if task.artifacts:
if task.artifacts is not None:
return []
return [
AgentResponseUpdate(
contents=[],
role="assistant",
response_id=task.id,
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
raw_representation=task,
)
]
@@ -488,16 +474,18 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
role="assistant",
response_id=task.id,
continuation_token=token,
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
raw_representation=task,
)
]
# Surface message content from in-progress status updates (e.g. working state)
# Only emitted when the caller opts in (streaming), so non-streaming
# consumers keep receiving only terminal task outputs.
if (
emit_intermediate
and status.state in IN_PROGRESS_TASK_STATES
and status.HasField("message")
and status.message is not None
and status.message.parts
):
contents = self._parse_contents_from_a2a(status.message.parts)
@@ -505,9 +493,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
return [
AgentResponseUpdate(
contents=contents,
role="assistant" if status.message.role == A2ARole.ROLE_AGENT else "user",
role="assistant" if status.message.role == A2ARole.agent else "user",
response_id=task.id,
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
raw_representation=task,
)
]
@@ -522,9 +510,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
contents = self._parse_contents_from_a2a(update_event.artifact.parts)
if not contents:
return []
artifact_meta = MessageToDict(update_event.artifact.metadata) if update_event.artifact.metadata else {}
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
merged_metadata = {**artifact_meta, **event_meta} or None
merged_metadata = {
**(update_event.artifact.metadata or {}),
**(update_event.metadata or {}),
} or None
return [
AgentResponseUpdate(
contents=contents,
@@ -539,21 +528,22 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
if not isinstance(update_event, TaskStatusUpdateEvent):
return []
if not update_event.status.HasField("message") or not update_event.status.message.parts:
message = update_event.status.message
if message is None or not message.parts:
return []
message = update_event.status.message
contents = self._parse_contents_from_a2a(message.parts)
if not contents:
return []
msg_meta = MessageToDict(message.metadata) if message.metadata else {}
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
merged_metadata = {**msg_meta, **event_meta} or None
merged_metadata = {
**(message.metadata or {}),
**(update_event.metadata or {}),
} or None
return [
AgentResponseUpdate(
contents=contents,
role="assistant" if message.role == A2ARole.ROLE_AGENT else "user",
role="assistant" if message.role == A2ARole.agent else "user",
response_id=update_event.task_id,
additional_properties={"a2a_metadata": merged_metadata} if merged_metadata else None,
raw_representation=update_event,
@@ -582,7 +572,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
is still in progress, or ``None`` when it has reached a terminal state.
"""
task_id = continuation_token["task_id"]
task = await self.client.get_task(GetTaskRequest(id=task_id))
task = await self.client.get_task(TaskQueryParams(id=task_id))
updates = self._updates_from_task(task, background=True)
if updates:
return AgentResponse.from_updates(updates)
@@ -617,15 +607,19 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError("Text content requires a non-null text value")
parts.append(
A2APart(
text=content.text,
metadata=content.additional_properties or {},
root=TextPart(
text=content.text,
metadata=content.additional_properties,
)
)
)
case "error":
parts.append(
A2APart(
text=content.message or "An error occurred.",
metadata=content.additional_properties or {},
root=TextPart(
text=content.message or "An error occurred.",
metadata=content.additional_properties,
)
)
)
case "uri":
@@ -633,20 +627,27 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError("URI content requires a non-null uri value")
parts.append(
A2APart(
url=content.uri,
media_type=content.media_type or "",
metadata=content.additional_properties or {},
root=FilePart(
file=FileWithUri(
uri=content.uri,
mime_type=content.media_type,
),
metadata=content.additional_properties,
)
)
)
case "data":
if content.uri is None:
raise ValueError("Data content requires a non-null uri value")
base64_data = get_uri_data(content.uri)
parts.append(
A2APart(
raw=base64.b64decode(base64_data),
media_type=content.media_type or "",
metadata=content.additional_properties or {},
root=FilePart(
file=FileWithBytes(
bytes=get_uri_data(content.uri),
mime_type=content.media_type,
),
metadata=content.additional_properties,
)
)
)
case "hosted_file":
@@ -654,91 +655,93 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError("Hosted file content requires a non-null file_id value")
parts.append(
A2APart(
url=content.file_id,
metadata=content.additional_properties or {},
root=FilePart(
file=FileWithUri(
uri=content.file_id,
mime_type=None, # HostedFileContent doesn't specify media_type
),
metadata=content.additional_properties,
)
)
)
case _:
raise ValueError(f"Unknown content type: {content.type}")
a2a_metadata = message.additional_properties.get("a2a_metadata")
metadata = message.additional_properties.get("a2a_metadata")
return A2AMessage(
role=A2ARole.ROLE_USER,
role=A2ARole("user"),
parts=parts,
message_id=message.message_id or uuid.uuid4().hex,
context_id=message.additional_properties.get("context_id") or context_id,
metadata=a2a_metadata or {},
metadata=metadata,
)
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
"""Parse A2A Parts into Agent Framework Content.
Transforms A2A protocol Parts into framework-native Content objects,
handling text, url, raw, and data parts with metadata preservation.
handling text, file (URI/bytes), and data parts with metadata preservation.
"""
contents: list[Content] = []
for part in parts:
part_metadata = MessageToDict(part.metadata) if part.metadata else None
content_type = part.WhichOneof("content")
match content_type:
inner_part = part.root
match inner_part.kind:
case "text":
contents.append(
Content.from_text(
text=part.text,
additional_properties=part_metadata,
raw_representation=part,
text=inner_part.text,
additional_properties=inner_part.metadata,
raw_representation=inner_part,
)
)
case "url":
contents.append(
Content.from_uri(
uri=part.url,
media_type=part.media_type or "",
additional_properties=part_metadata,
raw_representation=part,
case "file":
if isinstance(inner_part.file, FileWithUri):
contents.append(
Content.from_uri(
uri=inner_part.file.uri,
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
raw_representation=inner_part,
)
)
)
case "raw":
contents.append(
Content.from_data(
data=part.raw,
media_type=part.media_type or "",
additional_properties=part_metadata,
raw_representation=part,
elif isinstance(inner_part.file, FileWithBytes):
contents.append(
Content.from_data(
data=base64.b64decode(inner_part.file.bytes),
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
raw_representation=inner_part,
)
)
)
case "data":
from google.protobuf.json_format import MessageToJson
contents.append(
Content.from_text(
text=MessageToJson(part.data),
additional_properties=part_metadata,
raw_representation=part,
text=json.dumps(inner_part.data),
additional_properties=inner_part.metadata,
raw_representation=inner_part,
)
)
case _:
raise ValueError(f"Unknown Part content type: {content_type}")
raise ValueError(f"Unknown Part kind: {inner_part.kind}")
return contents
def _parse_messages_from_task(self, task: Task) -> list[Message]:
"""Parse A2A Task artifacts into Messages with ASSISTANT role."""
messages: list[Message] = []
if task.artifacts:
if task.artifacts is not None:
for artifact in task.artifacts:
messages.append(self._parse_message_from_artifact(artifact))
elif task.history:
elif task.history is not None and len(task.history) > 0:
# Include the last history item as the agent response
history_item = task.history[-1]
contents = self._parse_contents_from_a2a(history_item.parts)
history_metadata = MessageToDict(history_item.metadata) if history_item.metadata else None
messages.append(
Message(
role="assistant" if history_item.role == A2ARole.ROLE_AGENT else "user",
role="assistant" if history_item.role == A2ARole.agent else "user",
contents=contents,
additional_properties=history_metadata,
additional_properties=history_item.metadata,
raw_representation=history_item,
)
)
@@ -748,10 +751,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
def _parse_message_from_artifact(self, artifact: Artifact) -> Message:
"""Parse A2A Artifact into Message using part contents."""
contents = self._parse_contents_from_a2a(artifact.parts)
artifact_metadata = MessageToDict(artifact.metadata) if artifact.metadata else None
return Message(
role="assistant",
contents=contents,
additional_properties=artifact_metadata,
additional_properties=artifact.metadata,
raw_representation=artifact,
)
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"a2a-sdk>=1.0.0,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
[tool.uv]
+239 -148
View File
@@ -9,13 +9,16 @@ import httpx
from a2a.types import (
AgentCard,
Artifact,
DataPart,
FilePart,
FileWithUri,
Part,
StreamResponse,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
TextPart,
)
from a2a.types import Message as A2AMessage
from a2a.types import Role as A2ARole
@@ -40,42 +43,59 @@ class MockA2AClient:
def __init__(self) -> None:
self.call_count: int = 0
self.responses: list[StreamResponse] = []
self.subscribe_responses: list[StreamResponse] = []
self.responses: list[Any] = []
self.resubscribe_responses: list[Any] = []
self.get_task_response: Task | None = None
self.last_message: Any = None
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
"""Add a mock Message response."""
# Create actual TextPart instance and wrap it in Part
text_part = Part(root=TextPart(text=text))
# Create actual Message instance
message = A2AMessage(
message_id=message_id,
role=A2ARole.ROLE_AGENT if role == "agent" else A2ARole.ROLE_USER,
parts=[Part(text=text)],
message_id=message_id, role=A2ARole.agent if role == "agent" else A2ARole.user, parts=[text_part]
)
self.responses.append(StreamResponse(message=message))
self.responses.append(message)
def add_task_response(self, task_id: str, artifacts: list[dict[str, Any]]) -> None:
"""Add a mock Task response."""
# Create mock artifacts
mock_artifacts = []
for artifact_data in artifacts:
# Create actual TextPart instance and wrap it in Part
text_part = Part(root=TextPart(text=artifact_data.get("content", "Test content")))
artifact = Artifact(
artifact_id=artifact_data.get("id", str(uuid4())),
name=artifact_data.get("name", "test-artifact"),
parts=[Part(text=artifact_data.get("content", "Test content"))],
description=artifact_data.get("description", "Test artifact"),
parts=[text_part],
)
mock_artifacts.append(artifact)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
task = Task(id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts)
self.responses.append(StreamResponse(task=task))
# Create task status
status = TaskStatus(state=TaskState.completed, message=None)
# Create actual Task instance
task = Task(
id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts if mock_artifacts else None
)
# Mock the ClientEvent tuple format
update_event = None # No specific update event for completed tasks
client_event = (task, update_event)
self.responses.append(client_event)
def add_in_progress_task_response(
self,
task_id: str,
context_id: str = "test-context",
state: TaskState = TaskState.TASK_STATE_WORKING,
state: TaskState = TaskState.working,
text: str | None = None,
role: A2ARole = A2ARole.ROLE_AGENT,
role: A2ARole = A2ARole.agent,
) -> None:
"""Add a mock in-progress Task response (non-terminal)."""
message = None
@@ -83,28 +103,30 @@ class MockA2AClient:
message = A2AMessage(
message_id=str(uuid4()),
role=role,
parts=[Part(text=text)],
parts=[Part(root=TextPart(text=text))],
)
status = TaskStatus(state=state, message=message)
task = Task(id=task_id, context_id=context_id, status=status)
self.responses.append(StreamResponse(task=task))
client_event = (task, None)
self.responses.append(client_event)
async def send_message(self, request: Any) -> AsyncIterator[StreamResponse]:
async def send_message(self, message: Any) -> AsyncIterator[Any]:
"""Mock send_message method that yields responses."""
self.last_message = getattr(request, "message", request)
self.last_message = message
self.call_count += 1
# All queued responses are delivered as a single streaming batch per call.
for response in self.responses:
yield response
self.responses.clear()
async def subscribe(self, request: Any) -> AsyncIterator[StreamResponse]:
"""Mock subscribe method that yields responses."""
async def resubscribe(self, request: Any) -> AsyncIterator[Any]:
"""Mock resubscribe method that yields responses."""
self.call_count += 1
for response in self.subscribe_responses:
for response in self.resubscribe_responses:
yield response
self.subscribe_responses.clear()
self.resubscribe_responses.clear()
async def get_task(self, request: Any) -> Task:
"""Mock get_task method that returns a task."""
@@ -260,16 +282,16 @@ async def test_run_with_task_response_no_artifacts(a2a_agent: A2AAgent, mock_a2a
async def test_run_with_unknown_response_type_raises_error(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test run() method with unknown response type raises NotImplementedError."""
# An empty StreamResponse has no payload set (WhichOneof returns None)
mock_a2a_client.responses.append(StreamResponse())
mock_a2a_client.responses.append("invalid_response")
with raises(NotImplementedError, match="Unsupported StreamResponse payload"):
with raises(NotImplementedError, match="Only Message and Task responses are supported"):
await a2a_agent.run("Test message")
def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
"""Test _parse_messages_from_task with task containing no artifacts."""
task = Task(id="test", context_id="test", status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED))
task = MagicMock()
task.artifacts = None
result = a2a_agent._parse_messages_from_task(task)
@@ -278,14 +300,28 @@ def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
"""Test _parse_messages_from_task with task containing artifacts."""
artifact1 = Artifact(artifact_id="art-1", parts=[Part(text="Content 1")])
artifact2 = Artifact(artifact_id="art-2", parts=[Part(text="Content 2")])
task = Task(
id="test",
context_id="test",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
artifacts=[artifact1, artifact2],
)
task = MagicMock()
# Create mock artifacts
artifact1 = MagicMock()
artifact1.artifact_id = "art-1"
text_part1 = MagicMock()
text_part1.root = MagicMock()
text_part1.root.kind = "text"
text_part1.root.text = "Content 1"
text_part1.root.metadata = None
artifact1.parts = [text_part1]
artifact2 = MagicMock()
artifact2.artifact_id = "art-2"
text_part2 = MagicMock()
text_part2.root = MagicMock()
text_part2.root.kind = "text"
text_part2.root.text = "Content 2"
text_part2.root.metadata = None
artifact2.parts = [text_part2]
task.artifacts = [artifact1, artifact2]
result = a2a_agent._parse_messages_from_task(task)
@@ -297,7 +333,16 @@ def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
"""Test _parse_message_from_artifact conversion."""
artifact = Artifact(artifact_id="test-artifact", parts=[Part(text="Artifact content")])
artifact = MagicMock()
artifact.artifact_id = "test-artifact"
text_part = MagicMock()
text_part.root = MagicMock()
text_part.root.kind = "text"
text_part.root.text = "Artifact content"
text_part.root.metadata = None
artifact.parts = [text_part]
result = a2a_agent._parse_message_from_artifact(artifact)
@@ -328,7 +373,7 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
agent = A2AAgent(name="Test Agent", client=MockA2AClient(), http_client=None)
# Create A2A parts
parts = [Part(text="First part"), Part(text="Second part")]
parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))]
# Convert to contents
contents = agent._parse_contents_from_a2a(parts)
@@ -353,7 +398,7 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None
# Verify conversion
assert len(a2a_message.parts) == 1
assert a2a_message.parts[0].text == "Test error message"
assert a2a_message.parts[0].root.text == "Test error message"
def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
@@ -368,8 +413,8 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
# Verify conversion
assert len(a2a_message.parts) == 1
assert a2a_message.parts[0].url == "http://example.com/file.pdf"
assert a2a_message.parts[0].media_type == "application/pdf"
assert a2a_message.parts[0].root.file.uri == "http://example.com/file.pdf"
assert a2a_message.parts[0].root.file.mime_type == "application/pdf"
def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
@@ -384,8 +429,8 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
# Verify conversion
assert len(a2a_message.parts) == 1
assert a2a_message.parts[0].raw == b"Hello World"
assert a2a_message.parts[0].media_type == "text/plain"
assert a2a_message.parts[0].root.file.bytes == "SGVsbG8gV29ybGQ="
assert a2a_message.parts[0].root.file.mime_type == "text/plain"
def test_prepare_message_for_a2a_empty_contents_raises_error(a2a_agent: A2AAgent) -> None:
@@ -473,10 +518,10 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
assert len(result.parts) == 4
# Check each part type
assert result.parts[0].WhichOneof("content") == "text" # Regular text
assert result.parts[1].WhichOneof("content") == "raw" # Binary data
assert result.parts[2].WhichOneof("content") == "url" # URI content
assert result.parts[3].WhichOneof("content") == "text" # JSON text remains as text (no parsing)
assert result.parts[0].root.kind == "text" # Regular text
assert result.parts[1].root.kind == "file" # Binary data
assert result.parts[2].root.kind == "file" # URI content
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
def test_prepare_message_for_a2a_forwards_context_id() -> None:
@@ -528,29 +573,19 @@ def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
def test_parse_contents_from_a2a_with_data_part() -> None:
"""Test conversion of A2A data Part."""
from google.protobuf.json_format import ParseDict
from google.protobuf.struct_pb2 import Struct, Value
"""Test conversion of A2A DataPart."""
agent = A2AAgent(client=MagicMock(), http_client=None)
# Create Part with data (protobuf Value containing a struct)
value = ParseDict({"key": "value", "number": 42}, Value())
metadata = Struct()
metadata.update({"source": "test"})
data_part = Part(data=value, metadata=metadata)
# Create DataPart
data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"}))
contents = agent._parse_contents_from_a2a([data_part])
assert len(contents) == 1
assert contents[0].type == "text"
# MessageToJson may format slightly differently — verify the parsed structure
import json
parsed = json.loads(contents[0].text)
assert parsed["key"] == "value"
assert parsed["number"] == 42
assert contents[0].text == '{"key": "value", "number": 42}'
assert contents[0].additional_properties == {"source": "test"}
@@ -558,11 +593,12 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None:
"""Test error handling for unknown A2A part kind."""
agent = A2AAgent(client=MagicMock(), http_client=None)
# Create a Part with no content field set (WhichOneof returns None)
empty_part = Part()
# Create a mock part with unknown kind
mock_part = MagicMock()
mock_part.root.kind = "unknown_kind"
with raises(ValueError, match="Unknown Part content type"):
agent._parse_contents_from_a2a([empty_part])
with raises(ValueError, match="Unknown Part kind: unknown_kind"):
agent._parse_contents_from_a2a([mock_part])
def test_prepare_message_for_a2a_with_hosted_file() -> None:
@@ -581,8 +617,14 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
# Verify the conversion
assert len(result.parts) == 1
part = result.parts[0]
assert part.WhichOneof("content") == "url"
assert part.url == "hosted://storage/document.pdf"
assert part.root.kind == "file"
# Verify it's a FilePart with FileWithUri
assert isinstance(part.root, FilePart)
assert isinstance(part.root.file, FileWithUri)
assert part.root.file.uri == "hosted://storage/document.pdf"
assert part.root.file.mime_type is None # HostedFileContent doesn't specify media_type
def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
@@ -590,8 +632,15 @@ def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
agent = A2AAgent(client=MagicMock(), http_client=None)
# Create Part with hosted file URL (simulating what A2A would send back)
file_part = Part(url="hosted://storage/document.pdf")
# Create FilePart with hosted file URI (simulating what A2A would send back)
file_part = Part(
root=FilePart(
file=FileWithUri(
uri="hosted://storage/document.pdf",
mime_type=None,
)
)
)
contents = agent._parse_contents_from_a2a([file_part]) # noqa: SLF001
@@ -622,11 +671,9 @@ def test_auth_interceptor_parameter() -> None:
def test_transport_negotiation_both_fail() -> None:
"""Test that RuntimeError is raised when both primary and fallback transport negotiation fail."""
# Create a mock agent card with supported_interfaces
# Create a mock agent card
mock_agent_card = MagicMock(spec=AgentCard)
mock_interface = MagicMock()
mock_interface.url = "http://test-agent.example.com"
mock_agent_card.supported_interfaces = [mock_interface]
mock_agent_card.url = "http://test-agent.example.com"
mock_agent_card.name = "Test Agent"
mock_agent_card.description = "A test agent"
@@ -704,7 +751,7 @@ def test_a2a_agent_initialization_with_timeout_parameter() -> None:
async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that a working (non-terminal) task yields an update with a continuation token when background=True."""
mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.TASK_STATE_WORKING)
mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.working)
response = await a2a_agent.run("Start long task", background=True)
@@ -716,7 +763,7 @@ async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a
async def test_submitted_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that a submitted task yields a continuation token when background=True."""
mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.TASK_STATE_SUBMITTED)
mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.submitted)
response = await a2a_agent.run("Submit task", background=True)
@@ -728,7 +775,7 @@ async def test_input_required_task_emits_continuation_token(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that an input_required task yields a continuation token when background=True."""
mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.TASK_STATE_INPUT_REQUIRED)
mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.input_required)
response = await a2a_agent.run("Need input", background=True)
@@ -738,7 +785,7 @@ async def test_input_required_task_emits_continuation_token(
async def test_working_task_no_token_without_background(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that background=False (default) does not emit continuation tokens for in-progress tasks."""
mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.TASK_STATE_WORKING)
mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.working)
response = await a2a_agent.run("Foreground task")
@@ -758,7 +805,7 @@ async def test_completed_task_has_no_continuation_token(a2a_agent: A2AAgent, moc
async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that streaming with background=True yields updates with continuation tokens."""
mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.TASK_STATE_WORKING)
mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.working)
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Stream task", stream=True, background=True):
@@ -773,14 +820,14 @@ async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_
async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that run() with continuation_token uses resubscribe instead of send_message."""
# Set up the resubscribe response (completed task)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
status = TaskStatus(state=TaskState.completed, message=None)
artifact = Artifact(
artifact_id="art-resume",
name="result",
parts=[Part(text="Resumed result")],
parts=[Part(root=TextPart(text="Resumed result"))],
)
task = Task(id="task-resume", context_id="ctx-r", status=status, artifacts=[artifact])
mock_a2a_client.subscribe_responses.append(StreamResponse(task=task))
mock_a2a_client.resubscribe_responses.append((task, None))
token = A2AContinuationToken(task_id="task-resume", context_id="ctx-r")
response = await a2a_agent.run(continuation_token=token)
@@ -794,17 +841,17 @@ async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_clien
async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that streaming run() with continuation_token and background=True uses resubscribe."""
# Still working
status_wip = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=None)
status_wip = TaskStatus(state=TaskState.working, message=None)
task_wip = Task(id="task-rs", context_id="ctx-rs", status=status_wip)
# Then completed
status_done = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
status_done = TaskStatus(state=TaskState.completed, message=None)
artifact = Artifact(
artifact_id="art-rs",
name="result",
parts=[Part(text="Stream resumed")],
parts=[Part(root=TextPart(text="Stream resumed"))],
)
task_done = Task(id="task-rs", context_id="ctx-rs", status=status_done, artifacts=[artifact])
mock_a2a_client.subscribe_responses.extend([StreamResponse(task=task_wip), StreamResponse(task=task_done)])
mock_a2a_client.resubscribe_responses.extend([(task_wip, None), (task_done, None)])
token = A2AContinuationToken(task_id="task-rs", context_id="ctx-rs")
updates: list[AgentResponseUpdate] = []
@@ -821,7 +868,7 @@ async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock
async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test poll_task returns continuation token when task is still in progress."""
status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=None)
status = TaskStatus(state=TaskState.working, message=None)
mock_a2a_client.get_task_response = Task(id="task-poll", context_id="ctx-p", status=status)
token = A2AContinuationToken(task_id="task-poll", context_id="ctx-p")
@@ -833,11 +880,11 @@ async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA
async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test poll_task returns result with no continuation token when task is complete."""
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
status = TaskStatus(state=TaskState.completed, message=None)
artifact = Artifact(
artifact_id="art-poll",
name="result",
parts=[Part(text="Poll result")],
parts=[Part(root=TextPart(text="Poll result"))],
)
mock_a2a_client.get_task_response = Task(
id="task-poll-done", context_id="ctx-pd", status=status, artifacts=[artifact]
@@ -1058,9 +1105,9 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl
task = Task(
id="task-cont",
context_id="ctx-cont",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None),
status=TaskStatus(state=TaskState.completed, message=None),
)
mock_a2a_client.subscribe_responses.append(StreamResponse(task=task))
mock_a2a_client.resubscribe_responses.append((task, None))
agent = A2AAgent(
name="Test Agent",
@@ -1129,10 +1176,8 @@ async def test_streaming_working_update_without_message_is_skipped(
async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that A2ARole.ROLE_USER in status message maps to role='user'."""
mock_a2a_client.add_in_progress_task_response(
"task-u", context_id="ctx-u", text="User echo", role=A2ARole.ROLE_USER
)
"""Test that A2ARole.user in status message maps to role='user'."""
mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user)
mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}])
updates: list[AgentResponseUpdate] = []
@@ -1179,9 +1224,9 @@ async def test_terminal_no_artifacts_after_working_with_content(
"""Test that a terminal task with no artifacts after working-state messages does not re-emit the working content."""
mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...")
# Terminal task with no artifacts and no history
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
status = TaskStatus(state=TaskState.completed, message=None)
task = Task(id="task-t", context_id="ctx-t", status=status)
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.responses.append((task, None))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1200,12 +1245,12 @@ async def test_streaming_working_update_with_empty_parts_is_skipped(
# Construct a message with an empty parts list (distinct from message=None)
message = A2AMessage(
message_id=str(uuid4()),
role=A2ARole.ROLE_AGENT,
role=A2ARole.agent,
parts=[],
)
status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=message)
status = TaskStatus(state=TaskState.working, message=message)
task = Task(id="task-ep", context_id="ctx-ep", status=status)
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.responses.append((task, None))
mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}])
updates: list[AgentResponseUpdate] = []
@@ -1220,12 +1265,13 @@ async def test_streaming_artifact_update_event_yields_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that streaming artifact update events yield incremental content."""
task = Task(id="task-art", context_id="ctx-art", status=TaskStatus(state=TaskState.working, message=None))
artifact = Artifact(
artifact_id="artifact-1",
parts=[Part(text="Hello")],
parts=[Part(root=TextPart(text="Hello"))],
)
update_event = TaskArtifactUpdateEvent(task_id="task-art", context_id="ctx-art", artifact=artifact, append=False)
mock_a2a_client.responses.append(StreamResponse(artifact_update=update_event))
mock_a2a_client.responses.append((task, update_event))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1245,15 +1291,17 @@ async def test_streaming_status_update_event_yields_content(
task_id="task-status",
context_id="ctx-status",
status=TaskStatus(
state=TaskState.TASK_STATE_WORKING,
state=TaskState.working,
message=A2AMessage(
message_id=str(uuid4()),
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Still working")],
role=A2ARole.agent,
parts=[Part(root=TextPart(text="Still working"))],
),
),
final=False,
)
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
task = Task(id="task-status", context_id="ctx-status", status=TaskStatus(state=TaskState.working, message=None))
mock_a2a_client.responses.append((task, update_event))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1269,12 +1317,13 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that streamed artifact chunks are not re-emitted from the final terminal task."""
working_task = Task(id="task-art-dup", context_id="ctx-art-dup", status=TaskStatus(state=TaskState.working))
first_chunk = TaskArtifactUpdateEvent(
task_id="task-art-dup",
context_id="ctx-art-dup",
artifact=Artifact(
artifact_id="artifact-dup",
parts=[Part(text="Hello ")],
parts=[Part(root=TextPart(text="Hello "))],
),
append=False,
)
@@ -1283,26 +1332,32 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_
context_id="ctx-art-dup",
artifact=Artifact(
artifact_id="artifact-dup",
parts=[Part(text="world")],
parts=[Part(root=TextPart(text="world"))],
),
append=True,
)
terminal_task = Task(
id="task-art-dup",
context_id="ctx-art-dup",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed, message=None),
artifacts=[
Artifact(
artifact_id="artifact-dup",
parts=[Part(text="Hello world")],
parts=[Part(root=TextPart(text="Hello world"))],
)
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-art-dup",
context_id="ctx-art-dup",
status=TaskStatus(state=TaskState.completed, message=None),
final=True,
)
mock_a2a_client.responses.extend([
StreamResponse(artifact_update=first_chunk),
StreamResponse(artifact_update=second_chunk),
StreamResponse(task=terminal_task),
(working_task, first_chunk),
(working_task, second_chunk),
(terminal_task, terminal_event),
])
stream = a2a_agent.run("Hello", stream=True)
@@ -1323,15 +1378,21 @@ async def test_streaming_terminal_task_artifacts_are_emitted_when_terminal_event
terminal_task = Task(
id="task-art-final",
context_id="ctx-art-final",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed, message=None),
artifacts=[
Artifact(
artifact_id="artifact-final",
parts=[Part(text="Final artifact")],
parts=[Part(root=TextPart(text="Final artifact"))],
)
],
)
mock_a2a_client.responses.append(StreamResponse(task=terminal_task))
terminal_event = TaskStatusUpdateEvent(
task_id="task-art-final",
context_id="ctx-art-final",
status=TaskStatus(state=TaskState.completed, message=None),
final=True,
)
mock_a2a_client.responses.append((terminal_task, terminal_event))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
@@ -1346,34 +1407,41 @@ async def test_streaming_terminal_task_only_emits_unstreamed_artifacts(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that the terminal task only emits artifacts that were not already streamed incrementally."""
working_task = Task(id="task-art-mixed", context_id="ctx-art-mixed", status=TaskStatus(state=TaskState.working))
streamed_chunk = TaskArtifactUpdateEvent(
task_id="task-art-mixed",
context_id="ctx-art-mixed",
artifact=Artifact(
artifact_id="artifact-streamed",
parts=[Part(text="Hello")],
parts=[Part(root=TextPart(text="Hello"))],
),
append=False,
)
terminal_task = Task(
id="task-art-mixed",
context_id="ctx-art-mixed",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed, message=None),
artifacts=[
Artifact(
artifact_id="artifact-streamed",
parts=[Part(text="Hello")],
parts=[Part(root=TextPart(text="Hello"))],
),
Artifact(
artifact_id="artifact-final",
parts=[Part(text="Goodbye")],
parts=[Part(root=TextPart(text="Goodbye"))],
),
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-art-mixed",
context_id="ctx-art-mixed",
status=TaskStatus(state=TaskState.completed, message=None),
final=True,
)
mock_a2a_client.responses.extend([
StreamResponse(artifact_update=streamed_chunk),
StreamResponse(task=terminal_task),
(working_task, streamed_chunk),
(terminal_task, terminal_event),
])
stream = a2a_agent.run("Hello", stream=True)
@@ -1395,11 +1463,11 @@ async def test_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client:
"""A2AMessage.metadata should appear on response.additional_properties."""
msg = A2AMessage(
message_id="msg-meta",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="hi")],
role=A2ARole.agent,
parts=[Part(root=TextPart(text="hi"))],
metadata={"source": "server", "trace_id": "abc"},
)
mock_a2a_client.responses.append(StreamResponse(message=msg))
mock_a2a_client.responses.append(msg)
response = await a2a_agent.run("hello")
assert response.additional_properties["a2a_metadata"]["source"] == "server"
@@ -1411,16 +1479,16 @@ async def test_artifact_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client
task = Task(
id="task-art-meta",
context_id="ctx",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed),
artifacts=[
Artifact(
artifact_id="a1",
parts=[Part(text="result")],
parts=[Part(root=TextPart(text="result"))],
metadata={"artifact_key": "artifact_value"},
),
],
)
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.responses.append((task, None))
response = await a2a_agent.run("go")
assert response.additional_properties["a2a_metadata"]["artifact_key"] == "artifact_value"
@@ -1431,13 +1499,13 @@ async def test_task_metadata_propagated_to_response(a2a_agent: A2AAgent, mock_a2
task = Task(
id="task-meta",
context_id="ctx",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed),
artifacts=[
Artifact(artifact_id="a1", parts=[Part(text="done")]),
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]),
],
metadata={"task_key": "task_value"},
)
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.responses.append((task, None))
response = await a2a_agent.run("go")
assert response.additional_properties["a2a_metadata"]["task_key"] == "task_value"
@@ -1450,22 +1518,33 @@ async def test_task_artifact_update_event_metadata_merged(a2a_agent: A2AAgent, m
context_id="ctx",
artifact=Artifact(
artifact_id="a1",
parts=[Part(text="chunk")],
parts=[Part(root=TextPart(text="chunk"))],
metadata={"from_artifact": True},
),
metadata={"from_event": True},
)
working_task = Task(
id="task-ae",
context_id="ctx",
status=TaskStatus(state=TaskState.working),
)
terminal_task = Task(
id="task-ae",
context_id="ctx",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed),
artifacts=[
Artifact(artifact_id="a1", parts=[Part(text="chunk")]),
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="chunk"))]),
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-ae",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
final=True,
)
mock_a2a_client.responses.extend([
StreamResponse(artifact_update=artifact_event),
StreamResponse(task=terminal_task),
(working_task, artifact_event),
(terminal_task, terminal_event),
])
stream = a2a_agent.run("hello", stream=True)
@@ -1484,27 +1563,39 @@ async def test_task_status_update_event_metadata_merged(a2a_agent: A2AAgent, moc
task_id="task-se",
context_id="ctx",
status=TaskStatus(
state=TaskState.TASK_STATE_WORKING,
state=TaskState.working,
message=A2AMessage(
message_id="m1",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="working...")],
role=A2ARole.agent,
parts=[Part(root=TextPart(text="working..."))],
metadata={"msg_key": "msg_val"},
),
),
final=False,
metadata={"event_key": "event_val"},
)
working_task = Task(
id="task-se",
context_id="ctx",
status=TaskStatus(state=TaskState.working),
)
terminal_task = Task(
id="task-se",
context_id="ctx",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed),
artifacts=[
Artifact(artifact_id="a1", parts=[Part(text="done")]),
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]),
],
)
terminal_event = TaskStatusUpdateEvent(
task_id="task-se",
context_id="ctx",
status=TaskStatus(state=TaskState.completed),
final=True,
)
mock_a2a_client.responses.extend([
StreamResponse(status_update=status_event),
StreamResponse(task=terminal_task),
(working_task, status_event),
(terminal_task, terminal_event),
])
stream = a2a_agent.run("hello", stream=True)
@@ -1522,17 +1613,17 @@ async def test_history_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a
task = Task(
id="task-hist",
context_id="ctx",
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
status=TaskStatus(state=TaskState.completed),
history=[
A2AMessage(
message_id="h1",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="reply")],
role=A2ARole.agent,
parts=[Part(root=TextPart(text="reply"))],
metadata={"history_key": "history_value"},
),
],
)
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.responses.append((task, None))
response = await a2a_agent.run("go")
assert response.additional_properties["a2a_metadata"]["history_key"] == "history_value"
@@ -1545,10 +1636,10 @@ async def test_continuation_token_update_carries_task_metadata(
task = Task(
id="task-cont",
context_id="ctx",
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
status=TaskStatus(state=TaskState.working),
metadata={"bg_key": "bg_value"},
)
mock_a2a_client.responses.append(StreamResponse(task=task))
mock_a2a_client.responses.append((task, None))
response = await a2a_agent.run("go", background=True)
assert response.continuation_token is not None
@@ -1561,10 +1652,10 @@ async def test_none_metadata_leaves_additional_properties_empty(
"""When A2A types have no metadata, additional_properties should remain empty/default."""
msg = A2AMessage(
message_id="msg-none",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="no meta")],
role=A2ARole.agent,
parts=[Part(root=TextPart(text="no meta"))],
)
mock_a2a_client.responses.append(StreamResponse(message=msg))
mock_a2a_client.responses.append(msg)
response = await a2a_agent.run("hello")
assert not response.additional_properties
+16 -12
View File
@@ -3,7 +3,7 @@ from asyncio import CancelledError
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from a2a.types import Part, Task, TaskState
from a2a.types import Task, TaskState, TextPart
from agent_framework import (
AgentResponseUpdate,
Content,
@@ -48,7 +48,7 @@ def mock_task() -> Task:
task = MagicMock(spec=Task)
task.id = str(uuid4())
task.context_id = str(uuid4())
task.state = TaskState.TASK_STATE_COMPLETED
task.state = TaskState.completed
return task
@@ -244,7 +244,7 @@ class TestA2AExecutorExecute:
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.new_task_from_user_message") as mock_new_task:
with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task:
mock_task = MagicMock(spec=Task)
mock_task.id = "task-new"
mock_task.context_id = "ctx-123"
@@ -341,7 +341,9 @@ class TestA2AExecutorExecute:
# Assert
mock_updater.update_status.assert_called()
call_args_list = mock_updater.update_status.call_args_list
assert any(call[1].get("state") == TaskState.TASK_STATE_CANCELED for call in call_args_list)
assert any(
call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list
)
async def test_execute_handles_generic_exception(
self,
@@ -380,12 +382,14 @@ class TestA2AExecutorExecute:
args, _ = mock_updater.new_agent_message.call_args
parts = args[0]
assert len(parts) == 1
assert isinstance(parts[0], Part)
assert parts[0].text == error_message
assert isinstance(parts[0].root, TextPart)
assert parts[0].root.text == error_message
call_args_list = mock_updater.update_status.call_args_list
assert any(
call[1].get("state") == TaskState.TASK_STATE_FAILED and call[1].get("message") == "error_message_obj"
call[1].get("state") == TaskState.failed
and call[1].get("final") is True
and call[1].get("message") == "error_message_obj"
for call in call_args_list
)
@@ -626,7 +630,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
assert call_args.kwargs["state"] == TaskState.working
assert mock_updater.new_agent_message.called
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
@@ -662,7 +666,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
assert call_args.kwargs["state"] == TaskState.working
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with URI content."""
@@ -679,7 +683,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
assert call_args.kwargs["state"] == TaskState.working
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with mixed content types."""
@@ -701,7 +705,7 @@ class TestA2AExecutorHandleEvents:
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
assert call_args.kwargs["state"] == TaskState.working
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with additional properties metadata."""
@@ -774,7 +778,7 @@ class TestA2AExecutorHandleEvents:
# Assert
call_kwargs = mock_updater.update_status.call_args.kwargs
assert call_kwargs["state"] == TaskState.TASK_STATE_WORKING
assert call_kwargs["state"] == TaskState.working
async def test_handle_agent_response_update_no_streamed_set(
self, executor: A2AExecutor, mock_updater: MagicMock
+7 -11
View File
@@ -5,15 +5,14 @@ import os
import sys
import uvicorn
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
from agent_definitions import AGENT_CARD_FACTORIES, AGENT_FACTORIES
from agent_executor import AgentFrameworkExecutor
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from starlette.applications import Starlette
# Load environment variables from .env file
load_dotenv()
@@ -97,14 +96,11 @@ def main() -> None:
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=task_store,
agent_card=agent_card,
)
app = Starlette(
routes=[
*create_agent_card_routes(agent_card),
*create_jsonrpc_routes(request_handler),
]
a2a_app = A2AStarletteApplication(
agent_card=agent_card,
http_handler=request_handler,
)
print(f"Starting A2A server: {agent_card.name}")
@@ -114,7 +110,7 @@ def main() -> None:
print()
uvicorn.run(
app,
a2a_app.build(),
host=args.host,
port=args.port,
)
@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from invoice_data import query_by_invoice_id, query_by_transaction_id, query_invoices
if TYPE_CHECKING:
@@ -94,11 +94,11 @@ def get_invoice_agent_card(url: str) -> AgentCard:
return AgentCard(
name="InvoiceAgent",
description="Handles requests relating to invoices.",
url=url,
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=_CAPABILITIES,
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
skills=[
AgentSkill(
id="id_invoice_agent",
@@ -116,11 +116,11 @@ def get_policy_agent_card(url: str) -> AgentCard:
return AgentCard(
name="PolicyAgent",
description="Handles requests relating to policies and customer communications.",
url=url,
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=_CAPABILITIES,
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
skills=[
AgentSkill(
id="id_policy_agent",
@@ -138,11 +138,11 @@ def get_logistics_agent_card(url: str) -> AgentCard:
return AgentCard(
name="LogisticsAgent",
description="Handles requests relating to logistics.",
url=url,
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=_CAPABILITIES,
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
skills=[
AgentSkill(
id="id_logistics_agent",
@@ -21,6 +21,7 @@ from a2a.types import (
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
TextPart,
)
if TYPE_CHECKING:
@@ -55,7 +56,8 @@ class AgentFrameworkExecutor(AgentExecutor):
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
status=TaskStatus(state=TaskState.working),
final=False,
)
)
@@ -66,10 +68,10 @@ class AgentFrameworkExecutor(AgentExecutor):
response_parts: list[Part] = []
for msg in response.messages:
if msg.text:
response_parts.append(Part(text=msg.text))
response_parts.append(TextPart(text=msg.text))
if not response_parts:
response_parts.append(Part(text=str(response)))
response_parts.append(TextPart(text=str(response)))
# Publish the agent's response as a completed message
await event_queue.enqueue_event(
@@ -77,13 +79,14 @@ class AgentFrameworkExecutor(AgentExecutor):
task_id=task_id,
context_id=context_id,
status=TaskStatus(
state=TaskState.TASK_STATE_COMPLETED,
state=TaskState.completed,
message=Message(
message_id=str(uuid.uuid4()),
role=Role.ROLE_AGENT,
role=Role.agent,
parts=response_parts,
),
),
final=True,
)
)
except asyncio.CancelledError:
@@ -94,13 +97,14 @@ class AgentFrameworkExecutor(AgentExecutor):
task_id=task_id,
context_id=context_id,
status=TaskStatus(
state=TaskState.TASK_STATE_FAILED,
state=TaskState.failed,
message=Message(
message_id=str(uuid.uuid4()),
role=Role.ROLE_AGENT,
parts=[Part(text=f"Agent error: {e}")],
role=Role.agent,
parts=[TextPart(text=f"Agent error: {e}")],
),
),
final=True,
)
)
@@ -113,6 +117,7 @@ class AgentFrameworkExecutor(AgentExecutor):
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.TASK_STATE_CANCELED),
status=TaskStatus(state=TaskState.canceled),
final=True,
)
)
@@ -1,20 +1,18 @@
# Copyright (c) Microsoft. All rights reserved.
import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
)
from agent_framework import Agent
from agent_framework.a2a import A2AExecutor
from agent_framework.openai import OpenAIChatClient
from dotenv import load_dotenv
from starlette.applications import Starlette
load_dotenv()
@@ -41,11 +39,11 @@ if __name__ == "__main__":
public_agent_card = AgentCard(
name="Europe Travel Agent",
description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.",
url="http://localhost:9999/",
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC")],
skills=[flight_skill, hotel_skill],
)
# --8<-- [end:AgentCard]
@@ -59,14 +57,14 @@ if __name__ == "__main__":
request_handler = DefaultRequestHandler(
agent_executor=A2AExecutor(agent),
task_store=InMemoryTaskStore(),
agent_card=public_agent_card,
)
server = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
]
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
)
server = server.build()
# print(schemas.get_schema(server.routes))
uvicorn.run(server, host="0.0.0.0", port=9999)
+4 -44
View File
@@ -67,22 +67,18 @@ overrides = [
[[package]]
name = "a2a-sdk"
version = "1.0.2"
version = "0.3.23"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "culsans", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "json-rpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/f3/1c312eae0298542eef1a096be378a3ad2d20b171ea0ac6be26b81f542720/a2a_sdk-1.0.2.tar.gz", hash = "sha256:e4ee4dd509894c32c9a6df728319875fa4f049e70ae82476fa447353e3a4b648", size = 375193, upload-time = "2026-04-24T13:50:24.303Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2fe24e0a85240a651006c12f79bdb37156adc760a96c44bc002ebda77916/a2a_sdk-0.3.23.tar.gz", hash = "sha256:7c46b8572c4633a2b41fced2833e11e62871e8539a5b3c782ba2ba1e33d213c2", size = 255265, upload-time = "2026-02-17T08:34:34.648Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/03/58c92a44e7b94a42614880df2365f074969e47067c4c736e31e855aca2fd/a2a_sdk-1.0.2-py3-none-any.whl", hash = "sha256:4dbc083b6808ee28207ac6daad263360f87612c37b2d06f5521efb530318141c", size = 234302, upload-time = "2026-04-24T13:50:22.412Z" },
{ url = "https://files.pythonhosted.org/packages/d4/20/77d119f19ab03449d3e6bc0b1f11296d593dae99775c1d891ab1e290e416/a2a_sdk-0.3.23-py3-none-any.whl", hash = "sha256:8c2f01dffbfdd3509eafc15c4684743e6ae75e69a5df5d6f87be214c948e7530", size = 145689, upload-time = "2026-02-17T08:34:33.263Z" },
]
[[package]]
@@ -172,7 +168,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "a2a-sdk", specifier = ">=1.0.0,<2" },
{ name = "a2a-sdk", specifier = ">=0.3.5,<0.3.24" },
{ name = "agent-framework-core", editable = "packages/core" },
]
@@ -979,20 +975,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
name = "aiologic"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sniffio", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "wrapt", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -2030,19 +2012,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
]
[[package]]
name = "culsans"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiologic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
@@ -3194,15 +3163,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "json-rpc"
version = "1.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" },
]
[[package]]
name = "jsonpath-ng"
version = "1.8.0"